[OCaml] Mobile-friendly clone of cgit.
feat add collapsible table of contents to README rendering
Insert a hyperlinked table of contents between the document title and metadata/body. The TOC: - Lists all body headings with fragment links matching heading IDs - Indents entries by heading level (h1-h6) - Renders as a native <details> disclosure, collapsed by default - Is omitted when the document has no headings
Changed files
lib/readme.ml
@@ -494,23 +494,56 @@
494
494
| Markdown -> ([], lines)
495
495
| Org -> split_org_metadata lines
496
496
in
497
Added:
let blocks = parse_blocks format lines in
498
Added:
(* Extract the Org title if present. *)
499
Added:
let title_text =
500
Added:
List.find_opt (fun (key, _) -> key = "title") metadata |> Option.map snd
501
Added:
in
502
Added:
let metadata_entries =
503
Added:
List.filter (fun (key, _) -> key <> "title") metadata
504
Added:
in
505
Added:
(* Collect body headings for the table of contents. *)
506
Added:
let body_headings =
507
Added:
List.filter_map
508
Added:
(function Heading (level, text) -> Some (level, text) | _ -> None)
509
Added:
blocks
510
Added:
in
511
Added:
(* Build the TOC: use a dedicated anchor instance so IDs match the render
512
Added:
pass which sees the same heading sequence. *)
513
Added:
let toc =
514
Added:
match body_headings with
515
Added:
| [] -> Ui.nothing
516
Added:
| _ ->
517
Added:
let toc_anchor = new_anchor () in
518
Added:
(* Consume the title first to keep the anchor counter in sync. *)
519
Added:
(match title_text with Some t -> ignore (toc_anchor t) | None -> ());
520
Added:
let toc_entries =
521
Added:
List.map
522
Added:
(fun (level, text) ->
523
Added:
let id = toc_anchor text in
524
Added:
Ui.item
525
Added:
~class_:(Printf.sprintf "readme-toc-%d" level)
526
Added:
[ Ui.text_link ~href:("#" ^ id) text ])
527
Added:
body_headings
528
Added:
in
529
Added:
Ui.disclosure ~class_:"readme-toc" ~summary_class:"readme-toc-summary"
530
Added:
~summary:[ Ui.text "Table of Contents" ]
531
Added:
[ Ui.items ~class_:"readme-toc-list" toc_entries ]
532
Added:
in
533
Added:
(* Render the document. *)
497
534
let anchor = new_anchor () in
498
Removed:
let title, metadata =
499
Removed:
match List.find_opt (fun (key, _) -> key = "title") metadata with
500
Removed:
| None -> (None, metadata)
501
Removed:
| Some (_, title) ->
502
Removed:
( Some (heading anchor 1 title),
503
Removed:
List.filter (fun (key, _) -> key <> "title") metadata )
535
Added:
let title =
536
Added:
match title_text with None -> None | Some t -> Some (heading anchor 1 t)
504
537
in
505
Removed:
let metadata =
506
Removed:
match metadata with
538
Added:
let metadata_node =
539
Added:
match metadata_entries with
507
540
| [] -> Ui.nothing
508
Removed:
| metadata ->
541
Added:
| entries ->
509
542
Ui.definitions ~class_:"readme-org-metadata"
510
543
(List.map
511
544
(fun (key, value) ->
512
545
(String.capitalize_ascii key, [ Ui.text value ]))
513
Removed:
metadata)
546
Added:
entries)
514
547
in
515
548
let class_ =
516
549
match format with
@@ -518,5 +551,5 @@
518
551
| Org -> "readme-document readme-org"
519
552
in
520
553
Ui.region ~class_
521
Removed:
(Option.to_list title @ [ metadata ]
522
Removed:
@ List.map (render_block format anchor) (parse_blocks format lines))
554
Added:
(Option.to_list title @ [ metadata_node; toc ]
555
Added:
@ List.map (render_block format anchor) blocks)
lib/static/readme.css
@@ -121,6 +121,66 @@
121
121
color: white;
122
122
}
123
123
124
Added:
/* Table of contents */
125
Added:
.readme-toc {
126
Added:
margin: 0.75rem 0 1.25rem;
127
Added:
border: 1px solid var(--color-border-subtle);
128
Added:
border-radius: 0.35rem;
129
Added:
background-color: #1e1e1e;
130
Added:
}
131
Added:
132
Added:
.readme-toc-summary {
133
Added:
cursor: pointer;
134
Added:
list-style: none;
135
Added:
padding: 0.6rem 1rem;
136
Added:
font-weight: 600;
137
Added:
font-size: 0.92rem;
138
Added:
color: var(--color-muted);
139
Added:
user-select: none;
140
Added:
}
141
Added:
142
Added:
.readme-toc-summary::-webkit-details-marker {
143
Added:
display: none;
144
Added:
}
145
Added:
146
Added:
.readme-toc-summary::marker {
147
Added:
content: "";
148
Added:
}
149
Added:
150
Added:
.readme-toc-summary::before {
151
Added:
content: "▸ ";
152
Added:
}
153
Added:
154
Added:
.readme-toc[open] > .readme-toc-summary::before {
155
Added:
content: "▾ ";
156
Added:
}
157
Added:
158
Added:
.readme-toc-list {
159
Added:
margin: 0;
160
Added:
padding: 0 1rem 0.6rem;
161
Added:
list-style: none;
162
Added:
}
163
Added:
164
Added:
.readme-toc-list li {
165
Added:
line-height: 1.7;
166
Added:
}
167
Added:
168
Added:
.readme-toc-list li a {
169
Added:
color: var(--color-link-hover);
170
Added:
text-decoration: none;
171
Added:
}
172
Added:
173
Added:
.readme-toc-list li a:hover {
174
Added:
text-decoration: underline;
175
Added:
}
176
Added:
177
Added:
.readme-toc-1 { padding-left: 0; }
178
Added:
.readme-toc-2 { padding-left: 1.2em; }
179
Added:
.readme-toc-3 { padding-left: 2.4em; }
180
Added:
.readme-toc-4 { padding-left: 3.6em; }
181
Added:
.readme-toc-5 { padding-left: 4.8em; }
182
Added:
.readme-toc-6 { padding-left: 6.0em; }
183
Added:
124
184
.readme-code-block {
125
185
margin: 1rem 0;
126
186
padding: 0.9rem 1rem;
test/test_readme.ml
@@ -66,6 +66,73 @@
66
66
"ordinary file is not a README" false
67
67
(Ogit.Readme.is_readme_filename "guide.md")
68
68
69
Added:
let test_markdown_toc () =
70
Added:
let html =
71
Added:
render ~filename:"README.md"
72
Added:
"# Project\n\n## Getting Started\n\nText.\n\n## API\n\nMore text.\n"
73
Added:
in
74
Added:
Alcotest.(check bool)
75
Added:
"TOC disclosure present" true
76
Added:
(contains html "readme-toc");
77
Added:
Alcotest.(check bool)
78
Added:
"TOC contains summary" true
79
Added:
(contains html "Table of Contents");
80
Added:
Alcotest.(check bool)
81
Added:
"TOC links to first heading" true
82
Added:
(contains html "href=\"#getting-started\"");
83
Added:
Alcotest.(check bool)
84
Added:
"TOC links to second heading" true
85
Added:
(contains html "href=\"#api\"");
86
Added:
Alcotest.(check bool)
87
Added:
"TOC entry has level class" true
88
Added:
(contains html "readme-toc-2")
89
Added:
90
Added:
let test_org_toc () =
91
Added:
let html =
92
Added:
render ~filename:"README.org"
93
Added:
"#+TITLE: My Project\n\
94
Added:
#+AUTHOR: Ada\n\n\
95
Added:
* Overview\n\n\
96
Added:
** Installation\n\n\
97
Added:
Text.\n\n\
98
Added:
* Usage\n"
99
Added:
in
100
Added:
Alcotest.(check bool)
101
Added:
"Org TOC disclosure present" true
102
Added:
(contains html "readme-toc");
103
Added:
Alcotest.(check bool)
104
Added:
"Org TOC links to Overview" true
105
Added:
(contains html "href=\"#overview\"");
106
Added:
Alcotest.(check bool)
107
Added:
"Org TOC links to Installation" true
108
Added:
(contains html "href=\"#installation\"");
109
Added:
Alcotest.(check bool)
110
Added:
"Org TOC links to Usage" true
111
Added:
(contains html "href=\"#usage\"");
112
Added:
Alcotest.(check bool)
113
Added:
"Org TOC level 2 entry" true
114
Added:
(contains html "readme-toc-2");
115
Added:
let find s sub =
116
Added:
let slen = String.length s and sublen = String.length sub in
117
Added:
let rec f i =
118
Added:
if i > slen - sublen then -1
119
Added:
else if String.sub s i sublen = sub then i
120
Added:
else f (i + 1)
121
Added:
in
122
Added:
f 0
123
Added:
in
124
Added:
let toc_at = find html "readme-toc\"" in
125
Added:
let meta_at = find html "readme-org-metadata" in
126
Added:
Alcotest.(check bool)
127
Added:
"Org metadata appears before TOC" true
128
Added:
(toc_at >= 0 && meta_at >= 0 && meta_at < toc_at)
129
Added:
130
Added:
let test_no_toc_without_headings () =
131
Added:
let html = render ~filename:"README.md" "Just a paragraph.\n" in
132
Added:
Alcotest.(check bool)
133
Added:
"no TOC when no headings" false
134
Added:
(contains html "readme-toc")
135
Added:
69
136
let test_org_inline_verbatim () =
70
137
let html =
71
138
render ~filename:"README.org" "* Notes\n\nUse =verbatim= for emphasis.\n"
@@ -198,6 +265,10 @@
198
265
Alcotest.test_case "Org document" `Quick test_org_document;
199
266
Alcotest.test_case "README filename detection" `Quick
200
267
test_readme_filename_detection;
268
Added:
Alcotest.test_case "Markdown TOC" `Quick test_markdown_toc;
269
Added:
Alcotest.test_case "Org TOC" `Quick test_org_toc;
270
Added:
Alcotest.test_case "No TOC without headings" `Quick
271
Added:
test_no_toc_without_headings;
201
272
Alcotest.test_case "Org verbatim inline" `Quick test_org_inline_verbatim;
202
273
Alcotest.test_case "Org code inline" `Quick test_org_inline_code;
203
274
Alcotest.test_case "Org link with description" `Quick