feat unified table of contents with nesting support

Add a common Ui.toc building block that both the README renderer and the commit diff view share. Entries support recursive children, rendered as nested <ul> elements for semantic hierarchy. - Ui.toc_entry accepts an optional children list for sub-entries - Ui.toc renders a collapsible disclosure with classes toc/toc-summary/toc-list - README TOC now builds a proper nested tree from heading levels - Commit detail pages show a TOC of changed files before the diff - CSS targets the shared base classes; nesting indentation is automatic - Tests updated to verify nested structure

Commit
26273f3d560a8db6a095305ec05b92f6c40c7890
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/prose/prose_format.ml
index eda76a7f..e7a3fe19 100644..100644
@@ -126,23 +126,26 @@
126 126 | Code_block (language, source) -> render_code_block language source
127 127
128 128 let render_toc ~title_text body_headings =
129 Removed: match body_headings with
130 Removed: | [] | [ _ ] -> Ui.nothing
131 Removed: | _ ->
132 Removed: let toc_anchor = new_anchor () in
133 Removed: (match title_text with Some t -> ignore (toc_anchor t) | None -> ());
134 Removed: let toc_entries =
135 Removed: List.map
136 Removed: (fun (level, text) ->
137 Removed: let id = toc_anchor text in
138 Removed: Ui.item
139 Removed: ~class_:(Printf.sprintf "readme-toc-%d" level)
140 Removed: [ Ui.text_link ~href:("#" ^ id) text ])
141 Removed: body_headings
142 Removed: in
143 Removed: Ui.disclosure ~class_:"readme-toc" ~summary_class:"readme-toc-summary"
144 Removed: ~summary:[ Ui.text "Table of Contents" ]
145 Removed: [ Ui.items ~class_:"readme-toc-list" toc_entries ]
129 Added: let toc_anchor = new_anchor () in
130 Added: (match title_text with Some t -> ignore (toc_anchor t) | None -> ());
131 Added: (* Build a nested tree from a flat (level, text) list. Headings at a deeper
132 Added: level than the current base become children of the preceding entry. *)
133 Added: let rec build base_level headings =
134 Added: match headings with
135 Added: | [] -> ([], [])
136 Added: | (level, _) :: _ when level < base_level -> ([], headings)
137 Added: | (level, text) :: rest ->
138 Added: let id = toc_anchor text in
139 Added: let children, rest' = build (level + 1) rest in
140 Added: let entry = Ui.toc_entry ~children ~href:("#" ^ id) text in
141 Added: let siblings, rest'' = build base_level rest' in
142 Added: (entry :: siblings, rest'')
143 Added: in
144 Added: let min_level =
145 Added: List.fold_left (fun acc (l, _) -> min acc l) max_int body_headings
146 Added: in
147 Added: let entries, _ = build min_level body_headings in
148 Added: Ui.toc ~class_:"readme-toc" ~title:"Table of Contents" entries
146 149
147 150 (** Render a document using the given format. *)
148 151 let render format content =
lib/static/readme.css
index 602dcad8..f6594323 100644..100644
@@ -122,14 +122,14 @@
122 122 }
123 123
124 124 /* Table of contents */
125 Removed: .readme-toc {
125 Added: .toc {
126 126 margin: 0.75rem 0 1.25rem;
127 127 border: 1px solid var(--color-border-subtle);
128 128 border-radius: 0.35rem;
129 129 background-color: #1e1e1e;
130 130 }
131 131
132 Removed: .readme-toc-summary {
132 Added: .toc-summary {
133 133 cursor: pointer;
134 134 list-style: none;
135 135 padding: 0.6rem 1rem;
@@ -139,47 +139,44 @@
139 139 user-select: none;
140 140 }
141 141
142 Removed: .readme-toc-summary::-webkit-details-marker {
142 Added: .toc-summary::-webkit-details-marker {
143 143 display: none;
144 144 }
145 145
146 Removed: .readme-toc-summary::marker {
146 Added: .toc-summary::marker {
147 147 content: "";
148 148 }
149 149
150 Removed: .readme-toc-summary::before {
150 Added: .toc-summary::before {
151 151 content: "▸ ";
152 152 }
153 153
154 Removed: .readme-toc[open] > .readme-toc-summary::before {
154 Added: .toc[open] > .toc-summary::before {
155 155 content: "▾ ";
156 156 }
157 157
158 Removed: .readme-toc-list {
158 Added: .toc-list {
159 159 margin: 0;
160 160 padding: 0 1rem 0.6rem;
161 161 list-style: none;
162 162 }
163 163
164 Removed: .readme-toc-list li {
164 Added: .toc-list .toc-list {
165 Added: padding: 0 0 0 1.2em;
166 Added: }
167 Added:
168 Added: .toc-list li {
165 169 line-height: 1.7;
166 170 }
167 171
168 Removed: .readme-toc-list li a {
172 Added: .toc-list li a {
169 173 color: var(--color-link-hover);
170 174 text-decoration: none;
171 175 }
172 176
173 Removed: .readme-toc-list li a:hover {
177 Added: .toc-list li a:hover {
174 178 text-decoration: underline;
175 179 }
176 Removed:
177 Removed: .readme-toc-1 { padding-left: 0; }
178 Removed: .readme-toc-2 { padding-left: 1.2em; }
179 Removed: .readme-toc-3 { padding-left: 2.4em; }
180 Removed: .readme-toc-4 { padding-left: 3.6em; }
181 Removed: .readme-toc-5 { padding-left: 4.8em; }
182 Removed: .readme-toc-6 { padding-left: 6.0em; }
183 180
184 181 .readme-code-block {
185 182 margin: 1rem 0;
lib/views/ui.ml
index 743fd0f3..a5be2e94 100644..100644
@@ -150,9 +150,10 @@
150 150 let chevron ?(class_ = "tree-chevron") () =
151 151 inline ~class_ ~decorative:true [ text "\xe2\x80\xba" ]
152 152
153 Removed: let disclosure ?class_ ?(expanded = false) ?summary_class ~summary children =
153 Added: let disclosure ?id ?class_ ?(expanded = false) ?summary_class ~summary children
154 Added: =
154 155 HTML.details
155 Removed: (opt_class class_ @ flag_open expanded)
156 Added: (opt_id id @ opt_class class_ @ flag_open expanded)
156 157 (HTML.summary (opt_class summary_class) summary :: children)
157 158
158 159 let css_toggle ~id:toggle_id ~toggle_class ~control_class ~label:control_label
@@ -174,6 +175,41 @@
174 175 [ text glyph ];
175 176 ]
176 177
178 Added: (* Table of contents *)
179 Added:
180 Added: type toc_entry = {
181 Added: toc_href : string;
182 Added: toc_label : string;
183 Added: toc_children : toc_entry list;
184 Added: }
185 Added:
186 Added: let toc_entry ?(children = []) ~href label =
187 Added: { toc_href = href; toc_label = label; toc_children = children }
188 Added:
189 Added: let rec toc_items entries =
190 Added: items ~class_:"toc-list"
191 Added: (List.map
192 Added: (fun { toc_href; toc_label; toc_children } ->
193 Added: let nested =
194 Added: match toc_children with [] -> [] | kids -> [ toc_items kids ]
195 Added: in
196 Added: item (text_link ~href:toc_href toc_label :: nested))
197 Added: entries)
198 Added:
199 Added: let toc ?class_ ~title entries =
200 Added: let rec count = function
201 Added: | [] -> 0
202 Added: | e :: rest -> 1 + count e.toc_children + count rest
203 Added: in
204 Added: match entries with
205 Added: | [] -> nothing
206 Added: | _ when count entries < 2 -> nothing
207 Added: | _ ->
208 Added: let outer_class = classes [ "toc"; Option.value class_ ~default:"" ] in
209 Added: disclosure ~class_:outer_class ~summary_class:"toc-summary"
210 Added: ~summary:[ text title ]
211 Added: [ toc_items entries ]
212 Added:
177 213 (* Trees *)
178 214
179 215 let tree_leaf ?(modifier = "") ~href label =
@@ -380,7 +416,9 @@
380 416 [ block ~class_:"diff-lines" (List.map line_node lines) ];
381 417 ]
382 418
383 Removed: let file_node { path; detail; sections; note } =
419 Added: let file_id index = Printf.sprintf "file-%d" (index + 1)
420 Added:
421 Added: let file_node index { path; detail; sections; note } =
384 422 let body =
385 423 match note with
386 424 | Some note -> [ paragraph_text ~class_:"binary-diff" note ]
@@ -389,11 +427,18 @@
389 427 disclosure ~class_:"diff-file" ~expanded:true
390 428 ~summary_class:"diff-file-header"
391 429 ~summary:[ text path ]
430 Added: ~id:(file_id index)
392 431 (block ~class_:"diff-meta" [ text detail ] :: body)
393 432
433 Added: let file_toc files =
434 Added: toc ~class_:"diff-toc" ~title:"Changed files"
435 Added: (List.mapi
436 Added: (fun index { path; _ } -> toc_entry ~href:("#" ^ file_id index) path)
437 Added: files)
438 Added:
394 439 let view ~empty_message = function
395 440 | [] -> [ paragraph_text empty_message ]
396 Removed: | files -> List.map file_node files
441 Added: | files -> file_toc files :: List.mapi file_node files
397 442 end
398 443
399 444 (* Document scaffolding *)
lib/views/ui.mli
index ebd27960..9f0f5298 100644..100644
@@ -156,6 +156,7 @@
156 156 technology. *)
157 157
158 158 val disclosure :
159 Added: ?id:string ->
159 160 ?class_:string ->
160 161 ?expanded:bool ->
161 162 ?summary_class:string ->
@@ -181,6 +182,26 @@
181 182 scripting.
182 183
183 184 @param label the accessible name of the control, whose [glyph] has none. *)
185 Added:
186 Added: (** {1 Table of contents} *)
187 Added:
188 Added: type toc_entry
189 Added: (** One entry in a table of contents. Build with {!val-toc_entry}. Entries may
190 Added: contain nested children to represent subheading hierarchy. *)
191 Added:
192 Added: val toc_entry : ?children:toc_entry list -> href:string -> string -> toc_entry
193 Added: (** A TOC entry linking to a fragment.
194 Added:
195 Added: @param children
196 Added: nested sub-entries displayed as an indented list beneath this entry. *)
197 Added:
198 Added: val toc : ?class_:string -> title:string -> toc_entry list -> node
199 Added: (** A collapsible table of contents with support for nested sub-entries. Renders
200 Added: as a disclosure widget with classes [toc], [toc-summary], and [toc-list].
201 Added: Nested children produce nested [toc-list] elements for semantic indentation.
202 Added: Returns {!nothing} when the list has fewer than two entries.
203 Added:
204 Added: @param class_ an additional modifier alongside the base [toc] class. *)
184 205
185 206 (** {1 Trees}
186 207
test/test_readme.ml
index 528023f1..d4c17287 100644..100644
@@ -10,6 +10,17 @@
10 10 in
11 11 fragment_length <= text_length && loop 0
12 12
13 Added: let count text fragment =
14 Added: let text_length = String.length text in
15 Added: let fragment_length = String.length fragment in
16 Added: let rec loop index acc =
17 Added: if index > text_length - fragment_length then acc
18 Added: else if String.sub text index fragment_length = fragment then
19 Added: loop (index + 1) (acc + 1)
20 Added: else loop (index + 1) acc
21 Added: in
22 Added: if fragment_length > text_length then 0 else loop 0 0
23 Added:
13 24 let render ~filename content =
14 25 Ogit.Prose.render ~filename content |> Dream_html.to_string
15 26
@@ -84,8 +95,8 @@
84 95 "TOC links to second heading" true
85 96 (contains html "href=\"#api\"");
86 97 Alcotest.(check bool)
87 Removed: "TOC entry has level class" true
88 Removed: (contains html "readme-toc-2")
98 Added: "TOC has nested list for subheadings" true
99 Added: (count html "toc-list" >= 2)
89 100
90 101 let test_org_toc () =
91 102 let html =
@@ -110,8 +121,8 @@
110 121 "Org TOC links to Usage" true
111 122 (contains html "href=\"#usage\"");
112 123 Alcotest.(check bool)
113 Removed: "Org TOC level 2 entry" true
114 Removed: (contains html "readme-toc-2");
124 Added: "Org TOC has nested list for subheadings" true
125 Added: (count html "toc-list" >= 2);
115 126 let find s sub =
116 127 let slen = String.length s and sublen = String.length sub in
117 128 let rec f i =