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

Commit
81fc888cc49710ae042d8fef9c18116a704e35c7
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/readme.ml
index 088e66ad..a106c3d2 100644..100644
@@ -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
index 97c56f27..602dcad8 100644..100644
@@ -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
index eabd855f..ac98ab98 100644..100644
@@ -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