feat link repo-list directories, share tree markup via Components

Directory rows in the repository list rendered their name in a bare span, so the only way to reach a project directory page was the nav breadcrumb. They now render an a.tree-link anchor to the Project_dir route, matching the files/ tree: clicking the summary padding or the chevron toggles the nested list, clicking the anchor navigates. Introduce lib/views/components.ml to hold markup shared between pages, and route both directory listings through a single tree_dir definition so their CSS contract cannot drift: - chevron, tree_dir, section_disclosure - nav_logo, repo_breadcrumb, rootnav, topnav, bottomnav, repo_toolbar - page and site types, re-exported by Layout for existing callers The chevron is now marked aria-hidden; it is decorative and its glyph was previously announced by screen readers.

Commit
bc1b51a009aec68401a5a693dbef2f13b00b1697
Author
Claude Sonnet 4 <claude@anthropic.invalid>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
README.org
index 69664a65..86c256d6 100644..100644
@@ -29,7 +29,12 @@
29 29 default-branch policy so operations in one request reuse the same
30 30 store.
31 31 4. =Views= renders data supplied by handlers. Views do not access the
32 Removed: filesystem or load configuration.
32 Added: filesystem or load configuration. =Views.Components= holds the
33 Added: markup fragments shared between pages — the navigation bars, the
34 Added: disclosure chevron, and the collapsible directory row used by both
35 Added: the repository file tree and the project directory listing — so that
36 Added: a single definition backs each CSS contract. =Views.Layout= composes
37 Added: those fragments into the page shell.
33 38 5. =Static_handler= serves assets embedded at build time by
34 39 =ocaml-crunch=.
35 40
lib/views/components.ml
index 00000000..bc17d9c9 000000..100644
@@ -0,0 +1,199 @@
1 Added: (* -*- mode: tuareg; -*- *)
2 Added:
3 Added: (** Reusable HTML building blocks shared across views.
4 Added:
5 Added: This module holds presentation fragments that appear on more than one page
6 Added: so their markup — and therefore their CSS contract — stays identical
7 Added: everywhere. Nothing here performs I/O or touches repository state. *)
8 Added:
9 Added: open Dream_html
10 Added:
11 Added: (** {1 Page identity} *)
12 Added:
13 Added: type page = Summary | Commits | Files | Branches | Tags | Readme
14 Added: type site = { user_name : string; root_title : string; nav_logo : string }
15 Added:
16 Added: let site ~user_name ~root_title ~nav_logo = { user_name; root_title; nav_logo }
17 Added:
18 Added: let page_to_nav_item repo = function
19 Added: | Summary -> (Routes.Repo repo, "Summary", Summary)
20 Added: | Commits -> (Routes.Commits repo, "Commits", Commits)
21 Added: | Files -> (Routes.Files repo, "Files", Files)
22 Added: | Branches -> (Routes.Branches repo, "Branches", Branches)
23 Added: | Tags -> (Routes.Tags repo, "Tags", Tags)
24 Added: | Readme -> (Routes.Readme repo, "README", Readme)
25 Added:
26 Added: (** {1 Disclosure widgets} *)
27 Added:
28 Added: (** Decorative disclosure indicator. Rotated by CSS when the enclosing [details]
29 Added: is open, so it carries no textual meaning and is hidden from assistive
30 Added: technology. *)
31 Added: let chevron () =
32 Added: HTML.(span [ class_ "tree-chevron"; Aria.hidden true ] [ txt "\xe2\x80\xba" ])
33 Added:
34 Added: (** A collapsible directory row.
35 Added:
36 Added: Renders [li.tree-dir > details > summary.tree-toggle] where the summary
37 Added: holds the chevron plus an anchor to [route]. Clicking the summary padding or
38 Added: chevron toggles the nested list; clicking the anchor navigates. Both the
39 Added: repository file tree and the project directory listing use this so their
40 Added: interaction model is identical.
41 Added:
42 Added: @param extra_class appended to the [li] class list (e.g. [" tree-hidden"]).
43 Added: @param expanded renders the [details] initially open.
44 Added: @param label
45 Added: anchor text, conventionally the directory name with a trailing slash.
46 Added: @param children [li] nodes for the nested list. *)
47 Added: let tree_dir ?(extra_class = "") ?(expanded = false) ~route ~label children =
48 Added: let details_attrs = if expanded then HTML.[ open_ ] else [] in
49 Added: (* Bound outside the [HTML] scope below, where [label] would otherwise
50 Added: resolve to [HTML.label]. *)
51 Added: let label_text = label in
52 Added: HTML.(
53 Added: li
54 Added: [ class_ "tree-dir%s" extra_class ]
55 Added: [
56 Added: details details_attrs
57 Added: [
58 Added: summary
59 Added: [ class_ "tree-toggle" ]
60 Added: [
61 Added: chevron ();
62 Added: Routes.link_to route
63 Added: ~other_attrs:[ class_ "tree-link" ]
64 Added: (txt "%s" label_text);
65 Added: ];
66 Added: ul [ class_ "tree-nested" ] children;
67 Added: ];
68 Added: ])
69 Added:
70 Added: (** A collapsible page section headed by [h1], used for the repository-list
71 Added: groupings on the root page. *)
72 Added: let section_disclosure ?(expanded = false) ~title:section_title children =
73 Added: let details_attrs = if expanded then HTML.[ open_ ] else [] in
74 Added: HTML.(
75 Added: details details_attrs
76 Added: (summary
77 Added: [ class_ "section-toggle" ]
78 Added: [ chevron (); h1 [] [ txt "%s" section_title ] ]
79 Added: :: children))
80 Added:
81 Added: (** {1 Navigation} *)
82 Added:
83 Added: (** Static assets may be configured as bare paths; make them root-relative
84 Added: unless they are already absolute or a data URI. *)
85 Added: let normalize_asset_url source =
86 Added: if
87 Added: String.starts_with ~prefix:"/" source
88 Added: || String.starts_with ~prefix:"http://" source
89 Added: || String.starts_with ~prefix:"https://" source
90 Added: || String.starts_with ~prefix:"data:" source
91 Added: then source
92 Added: else "/" ^ source
93 Added:
94 Added: let nav_logo ~href:logo_href ~alt:alt_text logo =
95 Added: HTML.(
96 Added: a
97 Added: [ id "nav-logo"; href "%s" logo_href ]
98 Added: [
99 Added: img
100 Added: [
101 Added: src "%s" (normalize_asset_url logo);
102 Added: alt "%s" alt_text;
103 Added: class_ "site-logo";
104 Added: ];
105 Added: ])
106 Added:
107 Added: (** Breadcrumb trail for a nested repository path. Every segment but the last
108 Added: links to its project directory; the last links to the repository summary. *)
109 Added: let repo_breadcrumb repo =
110 Added: let segments =
111 Added: String.split_on_char '/' repo |> List.filter (fun segment -> segment <> "")
112 Added: in
113 Added: let last_index = List.length segments - 1 in
114 Added: let nodes =
115 Added: List.mapi
116 Added: (fun index segment ->
117 Added: let path = String.concat "/" (List_ext.take (index + 1) segments) in
118 Added: let repo_link =
119 Added: if index = last_index then
120 Added: Routes.link_to (Repo repo) (txt "%s" segment)
121 Added: else Routes.link_to (Project_dir path) (txt "%s" segment)
122 Added: in
123 Added: if index = 0 then repo_link
124 Added: else
125 Added: HTML.(
126 Added: null
127 Added: [
128 Added: span [ class_ "nav-home-sep"; Aria.hidden true ] [ txt "/" ];
129 Added: repo_link;
130 Added: ]))
131 Added: segments
132 Added: in
133 Added: HTML.(span [ id "nav-home"; class_ "repo-hierarchy" ] nodes)
134 Added:
135 Added: (** Top navigation for pages that are not scoped to a repository. *)
136 Added: let rootnav ~title:nav_title ~nav_logo:logo ?home_href () =
137 Added: let logo_href, logo_alt =
138 Added: match home_href with
139 Added: | None -> ("https://git-scm.com", "Git website")
140 Added: | Some _ -> ("/", "Repository list")
141 Added: in
142 Added: let home_href = Option.value home_href ~default:"/" in
143 Added: HTML.(
144 Added: nav
145 Added: [ id "top"; Aria.label "Site navigation" ]
146 Added: [
147 Added: nav_logo ~href:logo_href ~alt:logo_alt logo;
148 Added: a [ id "nav-home"; href "%s" home_href ] [ txt "%s" nav_title ];
149 Added: ])
150 Added:
151 Added: (** Top navigation for repository-scoped pages. *)
152 Added: let topnav ?(active = Summary) ~nav_logo:logo repo =
153 Added: let nav_items =
154 Added: List.map (page_to_nav_item repo)
155 Added: [ Summary; Commits; Files; Branches; Tags; Readme ]
156 Added: in
157 Added: let li_of_item (route, text, page) =
158 Added: let attrs = if page = active then [ Aria.current `page ] else [] in
159 Added: HTML.li attrs [ Routes.link_to route (txt "%s" text) ]
160 Added: in
161 Added: HTML.(
162 Added: nav
163 Added: [ id "top"; Aria.label "Repository navigation" ]
164 Added: [
165 Added: nav_logo ~href:"/" ~alt:"Repository list" logo;
166 Added: repo_breadcrumb repo;
167 Added: input [ type_ "checkbox"; id "nav-toggle"; class_ "nav-toggle" ];
168 Added: label
169 Added: [ for_ "nav-toggle"; class_ "nav-hamburger"; Aria.label "Menu" ]
170 Added: [ txt "\xe2\x8b\xae" ];
171 Added: ul [ id "nav-links" ] (List.map li_of_item nav_items);
172 Added: ])
173 Added:
174 Added: (** Fixed bottom navigation, revealed by CSS on narrow viewports only. *)
175 Added: let bottomnav ?(active = Summary) repo =
176 Added: let items = [ Summary; Commits; Files; Readme ] in
177 Added: let li_of_item page =
178 Added: let route, nav_label, _ = page_to_nav_item repo page in
179 Added: let attrs =
180 Added: [ HTML.class_ "bottom-nav-item" ]
181 Added: @ if page = active then [ Aria.current `page ] else []
182 Added: in
183 Added: HTML.(li attrs [ Routes.link_to route (txt "%s" nav_label) ])
184 Added: in
185 Added: HTML.(
186 Added: nav
187 Added: [ id "bottom-nav"; Aria.label "Mobile navigation" ]
188 Added: [ ul [ id "bottom-nav-links" ] (List.map li_of_item items) ])
189 Added:
190 Added: (** Sticky secondary bar below the top nav. Collapses to nothing when empty so
191 Added: the [body.has-toolbar] sticky offsets stay consistent. *)
192 Added: let repo_toolbar children =
193 Added: match children with
194 Added: | [] -> HTML.null []
195 Added: | _ ->
196 Added: HTML.(
197 Added: div
198 Added: [ id "toolbar"; role `toolbar; Aria.label "Repository toolbar" ]
199 Added: children)
lib/views/layout.ml
index 85752c06..dc8929e9 100644..100644
@@ -2,11 +2,26 @@
2 2
3 3 open Dream_html
4 4
5 Removed: type page = Summary | Commits | Files | Branches | Tags | Readme
6 Removed: type site = { user_name : string; root_title : string; nav_logo : string }
5 Added: (* Page identity and site configuration live in [Components] so the navigation
6 Added: fragments can be built without depending on this module. They are re-exported
7 Added: here because callers address them as [Layout.Summary], [Layout.site], etc. *)
7 8
8 Removed: let site ~user_name ~root_title ~nav_logo = { user_name; root_title; nav_logo }
9 Added: type page = Components.page =
10 Added: | Summary
11 Added: | Commits
12 Added: | Files
13 Added: | Branches
14 Added: | Tags
15 Added: | Readme
9 16
17 Added: type site = Components.site = {
18 Added: user_name : string;
19 Added: root_title : string;
20 Added: nav_logo : string;
21 Added: }
22 Added:
23 Added: let site = Components.site
24 Added:
10 25 type body_data = {
11 26 title : string;
12 27 repo : string option;
@@ -17,108 +32,6 @@
17 32 home_href : string option;
18 33 }
19 34
20 Removed: let page_to_nav_item repo = function
21 Removed: | Summary -> (Routes.Repo repo, "Summary", Summary)
22 Removed: | Commits -> (Routes.Commits repo, "Commits", Commits)
23 Removed: | Files -> (Routes.Files repo, "Files", Files)
24 Removed: | Branches -> (Routes.Branches repo, "Branches", Branches)
25 Removed: | Tags -> (Routes.Tags repo, "Tags", Tags)
26 Removed: | Readme -> (Routes.Readme repo, "README", Readme)
27 Removed:
28 Removed: let normalize_asset_url source =
29 Removed: if
30 Removed: String.starts_with ~prefix:"/" source
31 Removed: || String.starts_with ~prefix:"http://" source
32 Removed: || String.starts_with ~prefix:"https://" source
33 Removed: || String.starts_with ~prefix:"data:" source
34 Removed: then source
35 Removed: else "/" ^ source
36 Removed:
37 Removed: let nav_logo ~href:logo_href ~alt:alt_text logo =
38 Removed: HTML.(
39 Removed: a
40 Removed: [ id "nav-logo"; href "%s" logo_href ]
41 Removed: [
42 Removed: img
43 Removed: [
44 Removed: src "%s" (normalize_asset_url logo);
45 Removed: alt "%s" alt_text;
46 Removed: class_ "site-logo";
47 Removed: ];
48 Removed: ])
49 Removed:
50 Removed: let rootnav ~title:nav_title ~nav_logo:logo ?home_href () =
51 Removed: let logo_href, logo_alt =
52 Removed: match home_href with
53 Removed: | None -> ("https://git-scm.com", "Git website")
54 Removed: | Some _ -> ("/", "Repository list")
55 Removed: in
56 Removed: let home_href = Option.value home_href ~default:"/" in
57 Removed: HTML.(
58 Removed: nav
59 Removed: [ id "top"; Aria.label "Site navigation" ]
60 Removed: [
61 Removed: nav_logo ~href:logo_href ~alt:logo_alt logo;
62 Removed: a [ id "nav-home"; href "%s" home_href ] [ txt "%s" nav_title ];
63 Removed: ])
64 Removed:
65 Removed: let repo_home repo =
66 Removed: let segments =
67 Removed: String.split_on_char '/' repo |> List.filter (fun segment -> segment <> "")
68 Removed: in
69 Removed: let last_index = List.length segments - 1 in
70 Removed: let nodes =
71 Removed: List.mapi
72 Removed: (fun index segment ->
73 Removed: let path = String.concat "/" (List_ext.take (index + 1) segments) in
74 Removed: let repo_link =
75 Removed: if index = last_index then
76 Removed: Routes.link_to (Repo repo) (txt "%s" segment)
77 Removed: else Routes.link_to (Project_dir path) (txt "%s" segment)
78 Removed: in
79 Removed: if index = 0 then repo_link
80 Removed: else
81 Removed: HTML.(
82 Removed: null
83 Removed: [
84 Removed: span [ class_ "nav-home-sep"; Aria.hidden true ] [ txt "/" ];
85 Removed: repo_link;
86 Removed: ]))
87 Removed: segments
88 Removed: in
89 Removed: HTML.(span [ id "nav-home"; class_ "repo-hierarchy" ] nodes)
90 Removed:
91 Removed: let topnav ?(active = Summary) ~nav_logo:logo repo =
92 Removed: let nav_items =
93 Removed: List.map (page_to_nav_item repo)
94 Removed: [ Summary; Commits; Files; Branches; Tags; Readme ]
95 Removed: in
96 Removed: let li_of_item (route, text, page) =
97 Removed: let attrs = if page = active then [ Aria.current `page ] else [] in
98 Removed: HTML.li attrs [ Routes.link_to route (txt "%s" text) ]
99 Removed: in
100 Removed: HTML.(
101 Removed: nav
102 Removed: [ id "top"; Aria.label "Repository navigation" ]
103 Removed: [
104 Removed: nav_logo ~href:"/" ~alt:"Repository list" logo;
105 Removed: repo_home repo;
106 Removed: input [ type_ "checkbox"; id "nav-toggle"; class_ "nav-toggle" ];
107 Removed: label
108 Removed: [ for_ "nav-toggle"; class_ "nav-hamburger"; Aria.label "Menu" ]
109 Removed: [ txt "\xe2\x8b\xae" ];
110 Removed: ul [ id "nav-links" ] (List.map li_of_item nav_items);
111 Removed: ])
112 Removed:
113 Removed: let repo_toolbar children =
114 Removed: match children with
115 Removed: | [] -> HTML.null []
116 Removed: | _ ->
117 Removed: HTML.(
118 Removed: div
119 Removed: [ id "toolbar"; role `toolbar; Aria.label "Repository toolbar" ]
120 Removed: children)
121 Removed:
122 35 let page_header ~has_repo page_title subtitle =
123 36 let subtitle =
124 37 if String.starts_with ~prefix:"Unnamed repository" subtitle then ""
@@ -180,21 +93,6 @@
180 93 "";
181 94 ]
182 95
183 Removed: let bottomnav ?(active = Summary) repo =
184 Removed: let items = [ Summary; Commits; Files; Readme ] in
185 Removed: let li_of_item page =
186 Removed: let route, nav_label, _ = page_to_nav_item repo page in
187 Removed: let attrs =
188 Removed: [ HTML.class_ "bottom-nav-item" ]
189 Removed: @ if page = active then [ Aria.current `page ] else []
190 Removed: in
191 Removed: HTML.(li attrs [ Routes.link_to route (txt "%s" nav_label) ])
192 Removed: in
193 Removed: HTML.(
194 Removed: nav
195 Removed: [ id "bottom-nav"; Aria.label "Mobile navigation" ]
196 Removed: [ ul [ id "bottom-nav-links" ] (List.map li_of_item items) ])
197 Removed:
198 96 let body site page_data =
199 97 let open HTML in
200 98 let body_attrs =
@@ -207,13 +105,14 @@
207 105 a [ href "#main"; class_ "skip-link" ] [ txt "Skip to content" ];
208 106 (match page_data.repo with
209 107 | None ->
210 Removed: rootnav ~title:site.root_title ~nav_logo:site.nav_logo
108 Added: Components.rootnav ~title:site.root_title ~nav_logo:site.nav_logo
211 109 ?home_href:page_data.home_href ()
212 110 | Some repo ->
213 Removed: topnav ~active:page_data.active ~nav_logo:site.nav_logo repo);
111 Added: Components.topnav ~active:page_data.active ~nav_logo:site.nav_logo
112 Added: repo);
214 113 (match page_data.repo with
215 114 | None -> HTML.null []
216 Removed: | Some _ -> repo_toolbar page_data.toolbar);
115 Added: | Some _ -> Components.repo_toolbar page_data.toolbar);
217 116 HTML.main
218 117 [ id "main" ]
219 118 ((match page_data.repo with
@@ -223,7 +122,7 @@
223 122 :: page_data.content);
224 123 (match page_data.repo with
225 124 | None -> HTML.null []
226 Removed: | Some repo -> bottomnav ~active:page_data.active repo);
125 Added: | Some repo -> Components.bottomnav ~active:page_data.active repo);
227 126 page_footer site.user_name;
228 127 script []
229 128 {|document.addEventListener("DOMContentLoaded",function(){var b=document.getElementById("blob");if(!b||typeof hljs==="undefined")return;var cls=b.className.match(/language-([\w-]+)/);if(!cls)return;var lang=cls[1];b.querySelectorAll("span.line").forEach(function(el){var r=hljs.highlight(el.textContent,{language:lang,ignoreIllegals:true});el.innerHTML=r.value})});|};
lib/views/repo.ml
index a85d220c..ad8d561b 100644..100644
@@ -458,25 +458,9 @@
458 458 ];
459 459 ]
460 460 in
461 Removed: HTML.(
462 Removed: li
463 Removed: [ class_ "tree-dir%s" hidden_class ]
464 Removed: [
465 Removed: details []
466 Removed: [
467 Removed: summary
468 Removed: [ class_ "tree-toggle" ]
469 Removed: [
470 Removed: span [ class_ "tree-chevron" ] [ txt "\xe2\x80\xba" ];
471 Removed: Routes.link_to route
472 Removed: ~other_attrs:[ class_ "tree-link" ]
473 Removed: (txt "%s/" entry.name);
474 Removed: ];
475 Removed: ul
476 Removed: [ class_ "tree-nested" ]
477 Removed: (List.map (li_of_tree_node repo) displayed @ overflow_item);
478 Removed: ];
479 Removed: ])
461 Added: Components.tree_dir ~extra_class:hidden_class ~route
462 Added: ~label:(entry.name ^ "/")
463 Added: (List.map (li_of_tree_node repo) displayed @ overflow_item)
480 464
481 465 let summary context ?readme frequency =
482 466 let chart_section =
lib/views/root.ml
index 668809e8..1d9cc9b5 100644..100644
@@ -35,25 +35,9 @@
35 35 let child_prefix =
36 36 if prefix = "" then dir_name else prefix ^ "/" ^ dir_name
37 37 in
38 Removed: HTML.(
39 Removed: li
40 Removed: [ class_ "tree-dir" ]
41 Removed: [
42 Removed: details []
43 Removed: [
44 Removed: summary
45 Removed: [ class_ "tree-toggle" ]
46 Removed: [
47 Removed: span [ class_ "tree-chevron" ] [ txt "\xe2\x80\xba" ];
48 Removed: span [] [ txt "%s/" dir_name ];
49 Removed: ];
50 Removed: ul
51 Removed: [ class_ "tree-nested" ]
52 Removed: (List.map
53 Removed: (li_of_fs_node ~prefix:child_prefix ~dates)
54 Removed: children);
55 Removed: ];
56 Removed: ])
38 Added: Components.tree_dir ~route:(Routes.Project_dir child_prefix)
39 Added: ~label:(dir_name ^ "/")
40 Added: (List.map (li_of_fs_node ~prefix:child_prefix ~dates) children)
57 41
58 42 let render (site : Layout.site) ~dates ?(prefix = "") ?(favorites = [])
59 43 ?(archived = []) ?readme nodes =
@@ -69,16 +53,8 @@
69 53 section
70 54 [ class_ "repo-section repo-favorites" ]
71 55 [
72 Removed: details [ open_ ]
73 Removed: [
74 Removed: summary
75 Removed: [ class_ "section-toggle" ]
76 Removed: [
77 Removed: span [ class_ "tree-chevron" ] [ txt "\xe2\x80\xba" ];
78 Removed: h1 [] [ txt "Favorites" ];
79 Removed: ];
80 Removed: repo_list "repo-list-favorites" favorites;
81 Removed: ];
56 Added: Components.section_disclosure ~expanded:true ~title:"Favorites"
57 Added: [ repo_list "repo-list-favorites" favorites ];
82 58 ])
83 59 in
84 60 let main_section =
@@ -93,16 +69,9 @@
93 69 if nodes = [] then []
94 70 else
95 71 [
96 Removed: details [ open_ ]
97 Removed: [
98 Removed: summary
99 Removed: [ class_ "section-toggle" ]
100 Removed: [
101 Removed: span [ class_ "tree-chevron" ] [ txt "\xe2\x80\xba" ];
102 Removed: h1 [] [ txt "Repositories" ];
103 Removed: ];
104 Removed: repo_list "repo-list" nodes;
105 Removed: ];
72 Added: Components.section_disclosure ~expanded:true
73 Added: ~title:"Repositories"
74 Added: [ repo_list "repo-list" nodes ];
106 75 ]))
107 76 in
108 77 let archived_section =
@@ -113,16 +82,8 @@
113 82 section
114 83 [ class_ "repo-section repo-archived" ]
115 84 [
116 Removed: details []
117 Removed: [
118 Removed: summary
119 Removed: [ class_ "section-toggle" ]
120 Removed: [
121 Removed: span [ class_ "tree-chevron" ] [ txt "\xe2\x80\xba" ];
122 Removed: h1 [] [ txt "Archived" ];
123 Removed: ];
124 Removed: repo_list "repo-list-archived" archived;
125 Removed: ];
85 Added: Components.section_disclosure ~title:"Archived"
86 Added: [ repo_list "repo-list-archived" archived ];
126 87 ])
127 88 in
128 89 let readme_section =