Monorepo management for opam overlays
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add site command to generate static HTML monoverse map

Add a new Site module that generates a static index.html showing:
- All verse members with links to their repos
- Summary of common libraries and member-specific packages
- Detailed repository information with fork status

Also extends verse_registry to support name field for members
and description field for registries.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+738 -14
+99 -1
bin/main.ml
··· 1668 1668 in 1669 1669 Cmd.v info Term.(ret (const run $ url_or_pkg_arg $ as_arg $ upstream_arg $ from_arg $ fork_url_arg $ dry_run_arg $ logging_term)) 1670 1670 1671 + (* Site command *) 1672 + 1673 + let site_cmd = 1674 + let doc = "Generate a static HTML site representing the monoverse map" in 1675 + let man = 1676 + [ 1677 + `S Manpage.s_description; 1678 + `P 1679 + "Generates a static index.html file that maps the monoverse, showing all \ 1680 + verse members, their packages, and the relationships between them."; 1681 + `S "OUTPUT"; 1682 + `P "The generated site includes:"; 1683 + `I ("Members", "All verse members with links to their monorepo and opam repos"); 1684 + `I ("Summary", "Overview of common libraries and member-specific packages"); 1685 + `I ("Repository Details", "Each shared repo with packages and fork status"); 1686 + `S "FORK STATUS"; 1687 + `P "Use $(b,--status) to include fork relationship information:"; 1688 + `I ("+N", "You are N commits ahead of them"); 1689 + `I ("-N", "They are N commits ahead of you"); 1690 + `I ("+N/-M", "Diverged: you have N new, they have M new"); 1691 + `I ("sync", "Same commit"); 1692 + `S "DESIGN"; 1693 + `P "The HTML is designed to be:"; 1694 + `I ("-", "Simple and clean with a 10pt font"); 1695 + `I ("-", "Responsive and compact"); 1696 + `I ("-", "External links marked with icon and teal color"); 1697 + `S Manpage.s_examples; 1698 + `P "Generate site to default location (mono/index.html):"; 1699 + `Pre "monopam site"; 1700 + `P "Generate site with fork status (slower, fetches remotes):"; 1701 + `Pre "monopam site --status"; 1702 + `P "Generate site to custom location:"; 1703 + `Pre "monopam site -o /var/www/monoverse/index.html"; 1704 + `P "Print HTML to stdout:"; 1705 + `Pre "monopam site --stdout"; 1706 + ] 1707 + in 1708 + let info = Cmd.info "site" ~doc ~man in 1709 + let output_arg = 1710 + let doc = "Output file path. Defaults to mono/index.html." in 1711 + Arg.(value & opt (some string) None & info [ "o"; "output" ] ~docv:"FILE" ~doc) 1712 + in 1713 + let stdout_arg = 1714 + let doc = "Print HTML to stdout instead of writing to file." in 1715 + Arg.(value & flag & info [ "stdout" ] ~doc) 1716 + in 1717 + let status_arg = 1718 + let doc = "Include fork status (ahead/behind) for each repository. \ 1719 + This fetches from remotes and may be slower." in 1720 + Arg.(value & flag & info [ "status"; "s" ] ~doc) 1721 + in 1722 + let run output to_stdout with_status () = 1723 + Eio_main.run @@ fun env -> 1724 + with_config env @@ fun monopam_config -> 1725 + with_verse_config env @@ fun verse_config -> 1726 + let fs = Eio.Stdenv.fs env in 1727 + let proc = Eio.Stdenv.process_mgr env in 1728 + (* Pull/clone registry to get latest metadata *) 1729 + Fmt.pr "Syncing registry...@."; 1730 + let registry = 1731 + match Monopam.Verse_registry.clone_or_pull ~proc ~fs:(fs :> _ Eio.Path.t) ~config:verse_config () with 1732 + | Ok r -> r 1733 + | Error msg -> 1734 + Fmt.epr "Warning: Could not sync registry: %s@." msg; 1735 + Monopam.Verse_registry.{ name = "opamverse"; description = None; members = [] } 1736 + in 1737 + (* Compute forks if --status is requested *) 1738 + let forks = 1739 + if with_status then begin 1740 + Fmt.pr "Computing fork status...@."; 1741 + Some (Monopam.Forks.compute ~proc ~fs:(fs :> _ Eio.Path.t) 1742 + ~verse_config ~monopam_config ()) 1743 + end else None 1744 + in 1745 + if to_stdout then begin 1746 + let html = Monopam.Site.generate ~fs:(fs :> _ Eio.Path.t) ~config:verse_config ?forks ~registry () in 1747 + print_string html; 1748 + `Ok () 1749 + end else begin 1750 + let output_path = 1751 + match output with 1752 + | Some p -> ( 1753 + match Fpath.of_string p with 1754 + | Ok fp -> fp 1755 + | Error (`Msg _) -> Fpath.v p) 1756 + | None -> Fpath.(Monopam.Verse_config.mono_path verse_config / "index.html") 1757 + in 1758 + match Monopam.Site.write ~fs:(fs :> _ Eio.Path.t) ~config:verse_config ?forks ~registry ~output_path () with 1759 + | Ok () -> 1760 + Fmt.pr "Site generated: %a@." Fpath.pp output_path; 1761 + `Ok () 1762 + | Error msg -> 1763 + Fmt.epr "Error: %s@." msg; 1764 + `Error (false, "site generation failed") 1765 + end 1766 + in 1767 + Cmd.v info Term.(ret (const run $ output_arg $ stdout_arg $ status_arg $ logging_term)) 1768 + 1671 1769 (* Main command group *) 1672 1770 1673 1771 let main_cmd = ··· 1770 1868 in 1771 1869 let info = Cmd.info "monopam" ~version:"%%VERSION%%" ~doc ~man in 1772 1870 Cmd.group info 1773 - [ status_cmd; diff_cmd; pull_cmd; cherrypick_cmd; sync_cmd; changes_cmd; opam_cmd; doctor_cmd; verse_cmd; feature_cmd; fork_cmd; join_cmd; devcontainer_cmd ] 1871 + [ status_cmd; diff_cmd; pull_cmd; cherrypick_cmd; sync_cmd; changes_cmd; opam_cmd; doctor_cmd; verse_cmd; feature_cmd; fork_cmd; join_cmd; devcontainer_cmd; site_cmd ] 1774 1872 1775 1873 let () = exit (Cmd.eval main_cmd)
+1
lib/monopam.ml
··· 15 15 module Opam_transform = Opam_transform 16 16 module Sources_registry = Sources_registry 17 17 module Fork_join = Fork_join 18 + module Site = Site 18 19 19 20 let src = Logs.Src.create "monopam" ~doc:"Monopam operations" 20 21
+1
lib/monopam.mli
··· 39 39 module Opam_transform = Opam_transform 40 40 module Sources_registry = Sources_registry 41 41 module Fork_join = Fork_join 42 + module Site = Site 42 43 43 44 (** {1 High-Level Operations} *) 44 45
+535
lib/site.ml
··· 1 + (** Generate a static HTML site representing the monoverse map. *) 2 + 3 + (** Information about a package in the verse *) 4 + type pkg_info = { 5 + name : string; 6 + synopsis : string option; 7 + repo_name : string; 8 + dev_repo : string; (** Upstream git URL *) 9 + owners : string list; (** List of handles that have this package *) 10 + depends : string list; (** Package dependencies *) 11 + } 12 + 13 + (** Information about a repository (group of packages) *) 14 + type repo_info = { 15 + ri_name : string; 16 + ri_dev_repo : string; 17 + ri_packages : pkg_info list; 18 + ri_owners : string list; (** All handles that have any package from this repo *) 19 + ri_fork_status : (string * Forks.relationship) list; (** (handle, relationship) *) 20 + ri_dep_count : int; (** Number of dependencies (for sorting) *) 21 + } 22 + 23 + (** Information about a verse member *) 24 + type member_info = { 25 + handle : string; 26 + display_name : string; (** Name to display (from registry or handle) *) 27 + monorepo_url : string; 28 + opam_url : string; 29 + package_count : int; 30 + unique_packages : string list; (** Packages unique to this member *) 31 + } 32 + 33 + (** Aggregated site data *) 34 + type site_data = { 35 + local_handle : string; 36 + registry_name : string; 37 + registry_description : string option; 38 + members : member_info list; 39 + common_repos : repo_info list; (** Repos that exist in multiple members *) 40 + unique_repos : repo_info list; (** Repos unique to one member *) 41 + all_packages : pkg_info list; (** All packages *) 42 + } 43 + 44 + (** Scan a member's opam repo and return package info *) 45 + let scan_member_packages ~fs opam_repo_path = 46 + let pkgs, _errors = Opam_repo.scan_all ~fs opam_repo_path in 47 + List.map (fun pkg -> 48 + { 49 + name = Package.name pkg; 50 + synopsis = Package.synopsis pkg; 51 + repo_name = Package.repo_name pkg; 52 + dev_repo = Uri.to_string (Package.dev_repo pkg); 53 + owners = []; 54 + depends = Package.depends pkg; 55 + } 56 + ) pkgs 57 + 58 + (** Check if a directory exists *) 59 + let dir_exists ~fs path = 60 + let eio_path = Eio.Path.(fs / Fpath.to_string path) in 61 + match Eio.Path.kind ~follow:true eio_path with 62 + | `Directory -> true 63 + | _ -> false 64 + | exception _ -> false 65 + 66 + (** Collect site data from the workspace *) 67 + let collect_data ~fs ~config ?forks ~registry () = 68 + let local_handle = Verse_config.handle config in 69 + let local_opam_repo = Verse_config.opam_repo_path config in 70 + let verse_path = Verse_config.verse_path config in 71 + 72 + (* Scan local packages *) 73 + let local_pkgs = 74 + if dir_exists ~fs local_opam_repo then 75 + scan_member_packages ~fs local_opam_repo 76 + else [] 77 + in 78 + 79 + (* Build a map: package name -> list of (handle, pkg_info) *) 80 + let pkg_map : (string, (string * pkg_info) list) Hashtbl.t = Hashtbl.create 256 in 81 + 82 + (* Add local packages *) 83 + List.iter (fun pkg -> 84 + let existing = try Hashtbl.find pkg_map pkg.name with Not_found -> [] in 85 + Hashtbl.replace pkg_map pkg.name ((local_handle, pkg) :: existing) 86 + ) local_pkgs; 87 + 88 + let registry_name = registry.Verse_registry.name in 89 + let registry_description = registry.Verse_registry.description in 90 + 91 + (* Build handle -> display name lookup *) 92 + let handle_to_name = Hashtbl.create 16 in 93 + List.iter (fun (m : Verse_registry.member) -> 94 + let display = match m.name with Some n -> n | None -> m.handle in 95 + Hashtbl.replace handle_to_name m.handle display 96 + ) registry.Verse_registry.members; 97 + 98 + (* Get tracked handles from verse directory, excluding local handle *) 99 + let tracked_handles = 100 + if dir_exists ~fs verse_path then 101 + let eio_path = Eio.Path.(fs / Fpath.to_string verse_path) in 102 + try 103 + Eio.Path.read_dir eio_path 104 + |> List.filter (fun name -> 105 + not (String.ends_with ~suffix:"-opam" name) && 106 + name <> local_handle && 107 + dir_exists ~fs Fpath.(verse_path / name)) 108 + with Eio.Io _ -> [] 109 + else [] 110 + in 111 + 112 + (* Scan each tracked member's opam repo *) 113 + let member_infos = 114 + List.filter_map (fun handle -> 115 + let opam_path = Fpath.(verse_path / (handle ^ "-opam")) in 116 + if dir_exists ~fs opam_path then begin 117 + let pkgs = scan_member_packages ~fs opam_path in 118 + (* Add to package map *) 119 + List.iter (fun pkg -> 120 + let existing = try Hashtbl.find pkg_map pkg.name with Not_found -> [] in 121 + Hashtbl.replace pkg_map pkg.name ((handle, pkg) :: existing) 122 + ) pkgs; 123 + (* Look up member in registry for URLs *) 124 + let member = Verse_registry.find_member registry ~handle in 125 + let display_name = 126 + try Hashtbl.find handle_to_name handle 127 + with Not_found -> handle 128 + in 129 + Some { 130 + handle; 131 + display_name; 132 + monorepo_url = (match member with Some m -> m.monorepo | None -> ""); 133 + opam_url = (match member with Some m -> m.opamrepo | None -> ""); 134 + package_count = List.length pkgs; 135 + unique_packages = []; (* Will be filled in later *) 136 + } 137 + end else None 138 + ) tracked_handles 139 + in 140 + 141 + (* Add local member info *) 142 + let local_member = 143 + let member = Verse_registry.find_member registry ~handle:local_handle in 144 + let display_name = 145 + try Hashtbl.find handle_to_name local_handle 146 + with Not_found -> local_handle 147 + in 148 + { 149 + handle = local_handle; 150 + display_name; 151 + monorepo_url = (match member with Some m -> m.monorepo | None -> ""); 152 + opam_url = (match member with Some m -> m.opamrepo | None -> ""); 153 + package_count = List.length local_pkgs; 154 + unique_packages = []; 155 + } 156 + in 157 + 158 + (* Build final package list with owners *) 159 + let all_packages = 160 + Hashtbl.fold (fun _name entries acc -> 161 + match entries with 162 + | [] -> acc 163 + | (_, pkg) :: _ as all -> 164 + let owners = List.map fst all in 165 + (* Pick the best synopsis (first non-None) *) 166 + let synopsis = 167 + List.find_map (fun (_, p) -> p.synopsis) all 168 + in 169 + (* Merge depends from all sources *) 170 + let depends = 171 + List.concat_map (fun (_, p) -> p.depends) all 172 + |> List.sort_uniq String.compare 173 + in 174 + { pkg with owners; synopsis; depends } :: acc 175 + ) pkg_map [] 176 + |> List.sort (fun a b -> String.compare a.name b.name) 177 + in 178 + 179 + (* Build set of all package names for dependency counting *) 180 + let all_pkg_names = 181 + List.fold_left (fun s p -> Hashtbl.replace s p.name (); s) 182 + (Hashtbl.create 256) all_packages 183 + in 184 + 185 + (* Group packages by repo *) 186 + let repos_map : (string, pkg_info list) Hashtbl.t = Hashtbl.create 64 in 187 + List.iter (fun (pkg : pkg_info) -> 188 + let existing = try Hashtbl.find repos_map pkg.repo_name with Not_found -> [] in 189 + Hashtbl.replace repos_map pkg.repo_name (pkg :: existing) 190 + ) all_packages; 191 + 192 + (* Build forks status lookup from forks data if provided *) 193 + let forks_by_repo : (string, (string * Forks.relationship) list) Hashtbl.t = Hashtbl.create 64 in 194 + (match forks with 195 + | Some f -> 196 + List.iter (fun (ra : Forks.repo_analysis) -> 197 + let statuses = List.map (fun (h, _src, rel) -> (h, rel)) ra.verse_sources in 198 + Hashtbl.replace forks_by_repo ra.repo_name statuses 199 + ) f.Forks.repos 200 + | None -> ()); 201 + 202 + (* Build repo_info list with dependency counts *) 203 + let all_repos = 204 + Hashtbl.fold (fun repo_name pkgs acc -> 205 + let dev_repo = (List.hd pkgs).dev_repo in 206 + let owners = 207 + List.sort_uniq String.compare (List.concat_map (fun (p : pkg_info) -> p.owners) pkgs) 208 + in 209 + let fork_status = 210 + try Hashtbl.find forks_by_repo repo_name with Not_found -> [] 211 + in 212 + (* Count dependencies that are in our package set *) 213 + let dep_count = 214 + List.concat_map (fun (p : pkg_info) -> p.depends) pkgs 215 + |> List.filter (fun d -> Hashtbl.mem all_pkg_names d) 216 + |> List.sort_uniq String.compare 217 + |> List.length 218 + in 219 + { ri_name = repo_name; 220 + ri_dev_repo = dev_repo; 221 + ri_packages = List.sort (fun a b -> String.compare a.name b.name) pkgs; 222 + ri_owners = owners; 223 + ri_fork_status = fork_status; 224 + ri_dep_count = dep_count } :: acc 225 + ) repos_map [] 226 + (* Sort by dependency count descending (apps with most deps first), then by name *) 227 + |> List.sort (fun a b -> 228 + let cmp = compare b.ri_dep_count a.ri_dep_count in 229 + if cmp <> 0 then cmp else String.compare a.ri_name b.ri_name) 230 + in 231 + 232 + (* Separate common and unique repos *) 233 + let common_repos = List.filter (fun r -> List.length r.ri_owners > 1) all_repos in 234 + let unique_repos = List.filter (fun r -> List.length r.ri_owners = 1) all_repos in 235 + 236 + (* Compute unique packages per member *) 237 + let unique_by_handle = Hashtbl.create 32 in 238 + List.iter (fun (pkg : pkg_info) -> 239 + if List.length pkg.owners = 1 then begin 240 + let handle = List.hd pkg.owners in 241 + let existing = try Hashtbl.find unique_by_handle handle with Not_found -> [] in 242 + Hashtbl.replace unique_by_handle handle (pkg.name :: existing) 243 + end 244 + ) all_packages; 245 + 246 + (* Update member infos with unique packages *) 247 + let update_member m = 248 + let unique = try Hashtbl.find unique_by_handle m.handle with Not_found -> [] in 249 + { m with unique_packages = List.sort String.compare unique } 250 + in 251 + 252 + let all_members = local_member :: member_infos in 253 + let members = List.map update_member all_members in 254 + 255 + { local_handle; registry_name; registry_description; members; common_repos; unique_repos; all_packages } 256 + 257 + (** Escape HTML special characters *) 258 + let html_escape s = 259 + let buf = Buffer.create (String.length s) in 260 + String.iter (function 261 + | '<' -> Buffer.add_string buf "&lt;" 262 + | '>' -> Buffer.add_string buf "&gt;" 263 + | '&' -> Buffer.add_string buf "&amp;" 264 + | '"' -> Buffer.add_string buf "&quot;" 265 + | c -> Buffer.add_char buf c 266 + ) s; 267 + Buffer.contents buf 268 + 269 + (** External link SVG icon *) 270 + let external_link_icon = 271 + {|<svg class="ext-icon" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3.5 3H9V8.5M9 3L3 9"/></svg>|} 272 + 273 + (** Format fork relationship as short string *) 274 + let format_relationship = function 275 + | Forks.Same_url -> "=" 276 + | Forks.Same_commit -> "sync" 277 + | Forks.I_am_ahead n -> Printf.sprintf "+%d" n 278 + | Forks.I_am_behind n -> Printf.sprintf "-%d" n 279 + | Forks.Diverged { my_ahead; their_ahead; _ } -> Printf.sprintf "+%d/-%d" my_ahead their_ahead 280 + | Forks.Unrelated -> "unrel" 281 + | Forks.Not_fetched -> "?" 282 + 283 + (** Generate HTML from site data *) 284 + let generate_html data = 285 + let buf = Buffer.create 16384 in 286 + let add = Buffer.add_string buf in 287 + 288 + (* Build member lookups *) 289 + let member_urls = Hashtbl.create 16 in 290 + let member_names = Hashtbl.create 16 in 291 + List.iter (fun m -> 292 + Hashtbl.replace member_urls m.handle (m.monorepo_url, m.opam_url); 293 + Hashtbl.replace member_names m.handle m.display_name 294 + ) data.members; 295 + 296 + (* Helper to get display name for handle *) 297 + let get_name handle = 298 + try Hashtbl.find member_names handle with Not_found -> handle 299 + in 300 + 301 + add {|<!DOCTYPE html> 302 + <html lang="en"> 303 + <head> 304 + <meta charset="UTF-8"> 305 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 306 + <title>|}; 307 + add (html_escape data.registry_name); 308 + add {|</title> 309 + <style> 310 + * { margin: 0; padding: 0; box-sizing: border-box; } 311 + body { font: 10pt/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #333; max-width: 900px; margin: 0 auto; padding: 12px; } 312 + h1 { font-size: 14pt; font-weight: 600; margin-bottom: 4px; } 313 + .subtitle { font-size: 9pt; color: #666; margin-bottom: 12px; border-bottom: 1px solid #ddd; padding-bottom: 8px; } 314 + h2 { font-size: 11pt; font-weight: 600; margin: 16px 0 8px; color: #444; } 315 + h3 { font-size: 10pt; font-weight: 600; margin: 12px 0 6px; color: #555; } 316 + a { color: #0066cc; text-decoration: none; } 317 + a:hover { text-decoration: underline; } 318 + a.ext { color: #0088aa; } 319 + a.ext:hover { color: #006688; } 320 + .ext-icon { width: 10px; height: 10px; margin-left: 2px; vertical-align: baseline; position: relative; top: 1px; } 321 + .members { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; margin-bottom: 16px; } 322 + .member { background: #f8f8f8; padding: 8px; border-radius: 4px; border: 1px solid #e0e0e0; } 323 + .member-name { font-weight: 600; margin-bottom: 2px; } 324 + .member-handle { font-size: 8pt; color: #888; margin-bottom: 4px; } 325 + .member-stats { font-size: 9pt; color: #666; } 326 + .member-links { font-size: 9pt; margin-top: 4px; } 327 + .member-links a { margin-right: 8px; } 328 + .section { margin-bottom: 20px; } 329 + .summary { background: #fafafa; border: 1px solid #e8e8e8; border-radius: 4px; padding: 12px; margin-bottom: 16px; } 330 + .summary-title { font-weight: 600; margin-bottom: 8px; } 331 + .summary-list { font-size: 9pt; color: #555; line-height: 1.6; } 332 + .summary-item { display: inline-block; background: #fff; border: 1px solid #ddd; padding: 1px 6px; border-radius: 3px; margin: 2px 2px; } 333 + .summary-item a { color: #333; } 334 + .repo { margin-bottom: 12px; padding: 8px; background: #fafafa; border-radius: 4px; } 335 + .repo-header { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; } 336 + .repo-name { font-weight: 600; } 337 + .repo-name a { color: #333; } 338 + .repo-packages { font-size: 9pt; color: #666; margin-bottom: 4px; } 339 + .pkg-list { list-style: none; margin: 4px 0 0 0; padding: 0; } 340 + .pkg-list li { padding: 1px 0; color: #555; font-size: 8pt; } 341 + .pkg-list li::before { content: "-"; color: #999; margin-right: 6px; } 342 + .pkg-list b { font-weight: 500; color: #444; } 343 + .repo-forks { margin-top: 6px; } 344 + .repo-forks summary { font-size: 9pt; color: #666; cursor: pointer; } 345 + .repo-forks summary:hover { color: #444; } 346 + .fork-list { margin-top: 4px; font-size: 9pt; display: flex; flex-wrap: wrap; gap: 4px 12px; } 347 + .fork-item { color: #555; } 348 + .fork-item a { margin-left: 4px; } 349 + .fork-status { font-family: monospace; font-size: 8pt; padding: 1px 4px; border-radius: 2px; margin-left: 4px; } 350 + .fork-status.ahead { background: #e6f4ea; color: #137333; } 351 + .fork-status.behind { background: #fce8e6; color: #c5221f; } 352 + .fork-status.diverged { background: #fef7e0; color: #b06000; } 353 + .fork-status.sync { background: #e8f0fe; color: #1a73e8; } 354 + .unique-section { margin-top: 12px; } 355 + .unique-member { margin-bottom: 8px; } 356 + .unique-member-name { font-weight: 500; font-size: 9pt; color: #555; } 357 + .unique-list { font-size: 9pt; color: #666; margin-top: 2px; } 358 + .intro { background: #f0f7ff; border: 1px solid #d0e3f5; border-radius: 4px; padding: 10px 12px; margin-bottom: 16px; font-size: 9pt; line-height: 1.5; color: #444; } 359 + footer { margin-top: 20px; padding-top: 8px; border-top: 1px solid #ddd; font-size: 9pt; color: #888; } 360 + </style> 361 + </head> 362 + <body> 363 + |}; 364 + 365 + (* Title and description *) 366 + add (Printf.sprintf "<h1>%s</h1>\n" (html_escape data.registry_name)); 367 + (match data.registry_description with 368 + | Some desc -> add (Printf.sprintf "<div class=\"subtitle\">%s</div>\n" (html_escape desc)) 369 + | None -> add "<div class=\"subtitle\"></div>\n"); 370 + 371 + (* Intro section *) 372 + add {|<div class="intro"> 373 + This is an experiment in large-scale agentic coding using OCaml and OxCaml, where we're building environments to exchange vibe code at scale. 374 + Managed by <a class="ext" href="https://tangled.org/anil.recoil.org/monopam">monopam|}; add external_link_icon; add {|</a>, 375 + with the central registry at <a class="ext" href="https://tangled.org/eeg.cl.cam.ac.uk/opamverse">opamverse|}; add external_link_icon; add {|</a>. 376 + </div> 377 + |}; 378 + 379 + (* Members section *) 380 + add "<div class=\"section\">\n<h2>Members</h2>\n<div class=\"members\">\n"; 381 + List.iter (fun m -> 382 + add "<div class=\"member\">\n"; 383 + add (Printf.sprintf "<div class=\"member-name\"><a href=\"https://%s\">%s</a></div>\n" 384 + (html_escape m.handle) (html_escape m.display_name)); 385 + if m.display_name <> m.handle then 386 + add (Printf.sprintf "<div class=\"member-handle\">%s</div>\n" (html_escape m.handle)); 387 + add (Printf.sprintf "<div class=\"member-stats\">%d packages" m.package_count); 388 + if m.unique_packages <> [] then 389 + add (Printf.sprintf ", %d unique" (List.length m.unique_packages)); 390 + add "</div>\n"; 391 + if m.monorepo_url <> "" || m.opam_url <> "" then begin 392 + add "<div class=\"member-links\">"; 393 + if m.monorepo_url <> "" then 394 + add (Printf.sprintf "<a class=\"ext\" href=\"%s\">mono%s</a>" (html_escape m.monorepo_url) external_link_icon); 395 + if m.opam_url <> "" then 396 + add (Printf.sprintf "<a class=\"ext\" href=\"%s\">opam%s</a>" (html_escape m.opam_url) external_link_icon); 397 + add "</div>\n" 398 + end; 399 + add "</div>\n" 400 + ) data.members; 401 + add "</div>\n</div>\n"; 402 + 403 + (* Summary section *) 404 + add "<div class=\"section\">\n"; 405 + add "<div class=\"summary\">\n"; 406 + add (Printf.sprintf "<div class=\"summary-title\">Common Libraries (%d repos, %d packages)</div>\n" 407 + (List.length data.common_repos) 408 + (List.fold_left (fun acc r -> acc + List.length r.ri_packages) 0 data.common_repos)); 409 + add "<div class=\"summary-list\">\n"; 410 + List.iter (fun r -> 411 + add (Printf.sprintf "<span class=\"summary-item\"><a href=\"#%s\">%s</a> <span style=\"color:#888\">(%d)</span></span>\n" 412 + (html_escape r.ri_name) (html_escape r.ri_name) (List.length r.ri_packages)) 413 + ) data.common_repos; 414 + add "</div>\n</div>\n"; 415 + 416 + (* Member-specific summary *) 417 + let members_with_unique = List.filter (fun m -> m.unique_packages <> []) data.members in 418 + if members_with_unique <> [] then begin 419 + add "<div class=\"summary\">\n"; 420 + add "<div class=\"summary-title\">Member-Specific Packages</div>\n"; 421 + add "<div class=\"unique-section\">\n"; 422 + List.iter (fun m -> 423 + add "<div class=\"unique-member\">\n"; 424 + add (Printf.sprintf "<span class=\"unique-member-name\"><a href=\"https://%s\">%s</a>:</span> " 425 + (html_escape m.handle) (html_escape m.display_name)); 426 + add "<span class=\"unique-list\">"; 427 + add (String.concat ", " (List.map html_escape m.unique_packages)); 428 + add "</span>\n"; 429 + add "</div>\n" 430 + ) members_with_unique; 431 + add "</div>\n</div>\n" 432 + end; 433 + add "</div>\n"; 434 + 435 + (* Detailed repos section *) 436 + if data.common_repos <> [] then begin 437 + add "<div class=\"section\">\n<h2>Repository Details</h2>\n"; 438 + 439 + List.iter (fun r -> 440 + add (Printf.sprintf "<div class=\"repo\" id=\"%s\">\n" (html_escape r.ri_name)); 441 + add "<div class=\"repo-header\">"; 442 + add (Printf.sprintf "<span class=\"repo-name\"><a class=\"ext\" href=\"%s\">%s%s</a></span>" 443 + (html_escape r.ri_dev_repo) (html_escape r.ri_name) external_link_icon); 444 + add "</div>\n"; 445 + 446 + (* Packages list - compact with names *) 447 + add "<div class=\"repo-packages\">"; 448 + let pkg_names = List.map (fun (p : pkg_info) -> p.name) r.ri_packages in 449 + add (String.concat ", " (List.map html_escape pkg_names)); 450 + add "</div>\n"; 451 + 452 + (* Package descriptions as bullet list *) 453 + let pkg_descs = List.filter_map (fun (p : pkg_info) -> 454 + match p.synopsis with 455 + | Some s -> Some (p.name, s) 456 + | None -> None 457 + ) r.ri_packages in 458 + if pkg_descs <> [] then begin 459 + add "<ul class=\"pkg-list\">\n"; 460 + List.iter (fun (name, desc) -> 461 + add (Printf.sprintf "<li><b>%s</b>: %s</li>\n" (html_escape name) (html_escape desc)) 462 + ) pkg_descs; 463 + add "</ul>\n" 464 + end; 465 + 466 + (* Forks - at repo level with names *) 467 + if List.length r.ri_owners > 1 then begin 468 + let owner_links = List.map (fun h -> 469 + Printf.sprintf "<a href=\"https://%s\">%s</a>" (html_escape h) (html_escape (get_name h)) 470 + ) (List.sort String.compare r.ri_owners) in 471 + add "<details class=\"repo-forks\">\n"; 472 + add (Printf.sprintf "<summary>%d members (%s)</summary>\n" 473 + (List.length r.ri_owners) 474 + (String.concat ", " owner_links)); 475 + add "<div class=\"fork-list\">\n"; 476 + List.iter (fun handle -> 477 + let mono_url, _opam_url = 478 + try Hashtbl.find member_urls handle 479 + with Not_found -> ("", "") 480 + in 481 + add "<span class=\"fork-item\">"; 482 + add (Printf.sprintf "<a href=\"https://%s\">%s</a>" (html_escape handle) (html_escape (get_name handle))); 483 + (* Add status if available *) 484 + (match List.assoc_opt handle r.ri_fork_status with 485 + | Some rel -> 486 + let status_str = format_relationship rel in 487 + let status_class = 488 + match rel with 489 + | Forks.Same_url | Forks.Same_commit -> "sync" 490 + | Forks.I_am_ahead _ -> "ahead" 491 + | Forks.I_am_behind _ -> "behind" 492 + | Forks.Diverged _ -> "diverged" 493 + | _ -> "" 494 + in 495 + if status_class <> "" then 496 + add (Printf.sprintf "<span class=\"fork-status %s\">%s</span>" status_class status_str) 497 + else 498 + add (Printf.sprintf "<span class=\"fork-status\">%s</span>" status_str) 499 + | None -> ()); 500 + if mono_url <> "" then 501 + add (Printf.sprintf "<a class=\"ext\" href=\"%s/%s\">mono%s</a>" 502 + (html_escape mono_url) (html_escape r.ri_name) external_link_icon); 503 + add "</span>\n" 504 + ) (List.sort String.compare r.ri_owners); 505 + add "</div>\n</details>\n" 506 + end; 507 + 508 + add "</div>\n" 509 + ) data.common_repos; 510 + 511 + add "</div>\n" 512 + end; 513 + 514 + (* Footer with generation date *) 515 + let now = Unix.gettimeofday () in 516 + let tm = Unix.gmtime now in 517 + let date_str = Printf.sprintf "%04d-%02d-%02d" 518 + (tm.Unix.tm_year + 1900) (tm.Unix.tm_mon + 1) tm.Unix.tm_mday in 519 + add (Printf.sprintf "<footer>Generated by monopam on %s | %d members | %d repos | %d packages</footer>\n" 520 + date_str (List.length data.members) (List.length data.common_repos + List.length data.unique_repos) (List.length data.all_packages)); 521 + 522 + add "</body>\n</html>\n"; 523 + Buffer.contents buf 524 + 525 + (** Generate the site and return the HTML content *) 526 + let generate ~fs ~config ?forks ~registry () = 527 + let data = collect_data ~fs ~config ?forks ~registry () in 528 + generate_html data 529 + 530 + (** Write the site to a file *) 531 + let write ~fs ~config ?forks ~registry ~output_path () = 532 + let html = generate ~fs ~config ?forks ~registry () in 533 + let eio_path = Eio.Path.(fs / Fpath.to_string output_path) in 534 + Eio.Path.save ~create:(`Or_truncate 0o644) eio_path html; 535 + Ok ()
+82
lib/site.mli
··· 1 + (** Generate a static HTML site representing the monoverse map. 2 + 3 + The site command generates an index.html that shows: 4 + - All verse members with links to their repos 5 + - Summary of common libraries and member-specific packages 6 + - Detailed repository information with fork status *) 7 + 8 + (** {1 Types} *) 9 + 10 + (** Information about a package in the verse *) 11 + type pkg_info = { 12 + name : string; 13 + synopsis : string option; 14 + repo_name : string; 15 + dev_repo : string; (** Upstream git URL *) 16 + owners : string list; (** List of handles that have this package *) 17 + depends : string list; (** Package dependencies *) 18 + } 19 + 20 + (** Information about a repository (group of packages) *) 21 + type repo_info = { 22 + ri_name : string; 23 + ri_dev_repo : string; 24 + ri_packages : pkg_info list; 25 + ri_owners : string list; (** All handles that have any package from this repo *) 26 + ri_fork_status : (string * Forks.relationship) list; (** (handle, relationship) *) 27 + ri_dep_count : int; (** Number of dependencies (for sorting) *) 28 + } 29 + 30 + (** Information about a verse member *) 31 + type member_info = { 32 + handle : string; 33 + display_name : string; (** Name to display (from registry or handle) *) 34 + monorepo_url : string; 35 + opam_url : string; 36 + package_count : int; 37 + unique_packages : string list; (** Packages unique to this member *) 38 + } 39 + 40 + (** Aggregated site data *) 41 + type site_data = { 42 + local_handle : string; 43 + registry_name : string; 44 + registry_description : string option; 45 + members : member_info list; 46 + common_repos : repo_info list; (** Repos that exist in multiple members *) 47 + unique_repos : repo_info list; (** Repos unique to one member *) 48 + all_packages : pkg_info list; (** All packages *) 49 + } 50 + 51 + (** {1 Generation} *) 52 + 53 + val collect_data : 54 + fs:Eio.Fs.dir_ty Eio.Path.t -> 55 + config:Verse_config.t -> 56 + ?forks:Forks.t -> 57 + registry:Verse_registry.t -> 58 + unit -> 59 + site_data 60 + (** [collect_data ~fs ~config ?forks ~registry ()] scans the workspace and verse members 61 + to collect package information for the site. If [forks] is provided, 62 + includes fork status information for each repository. *) 63 + 64 + val generate : 65 + fs:Eio.Fs.dir_ty Eio.Path.t -> 66 + config:Verse_config.t -> 67 + ?forks:Forks.t -> 68 + registry:Verse_registry.t -> 69 + unit -> 70 + string 71 + (** [generate ~fs ~config ?forks ~registry ()] generates the HTML content for the site. *) 72 + 73 + val write : 74 + fs:Eio.Fs.dir_ty Eio.Path.t -> 75 + config:Verse_config.t -> 76 + ?forks:Forks.t -> 77 + registry:Verse_registry.t -> 78 + output_path:Fpath.t -> 79 + unit -> 80 + (unit, string) result 81 + (** [write ~fs ~config ?forks ~registry ~output_path ()] generates and writes the site 82 + to the specified output path. *)
+18 -13
lib/verse_registry.ml
··· 1 1 type member = { 2 2 handle : string; 3 + name : string option; 3 4 monorepo : string; 4 5 monorepo_branch : string option; 5 6 opamrepo : string; 6 7 opamrepo_branch : string option; 7 8 } 8 - type t = { name : string; members : member list } 9 + type t = { name : string; description : string option; members : member list } 9 10 10 11 let default_url = "https://tangled.org/eeg.cl.cam.ac.uk/opamverse" 11 12 ··· 27 28 let pp_member ppf m = 28 29 let mono_str = encode_url_with_branch m.monorepo m.monorepo_branch in 29 30 let opam_str = encode_url_with_branch m.opamrepo m.opamrepo_branch in 30 - Fmt.pf ppf "@[<hov 2>%s ->@ mono:%s@ opam:%s@]" m.handle mono_str opam_str 31 + let name_str = match m.name with Some n -> n | None -> m.handle in 32 + Fmt.pf ppf "@[<hov 2>%s (%s) ->@ mono:%s@ opam:%s@]" name_str m.handle mono_str opam_str 31 33 32 34 let pp ppf t = 33 - Fmt.pf ppf "@[<v>registry: %s@,members:@, @[<v>%a@]@]" t.name 35 + Fmt.pf ppf "@[<v>registry: %s%a@,members:@, @[<v>%a@]@]" t.name 36 + Fmt.(option (fun ppf s -> pf ppf "@,description: %s" s)) t.description 34 37 Fmt.(list ~sep:cut pp_member) 35 38 t.members 36 39 ··· 47 50 let member_codec : member Tomlt.t = 48 51 Tomlt.( 49 52 Table.( 50 - obj (fun handle monorepo_raw opamrepo_raw -> 53 + obj (fun handle name monorepo_raw opamrepo_raw -> 51 54 let monorepo, monorepo_branch = parse_url_with_branch monorepo_raw in 52 55 let opamrepo, opamrepo_branch = parse_url_with_branch opamrepo_raw in 53 - { handle; monorepo; monorepo_branch; opamrepo; opamrepo_branch }) 54 - |> mem "handle" string ~enc:(fun m -> m.handle) 55 - |> mem "monorepo" string ~enc:(fun m -> encode_url_with_branch m.monorepo m.monorepo_branch) 56 - |> mem "opamrepo" string ~enc:(fun m -> encode_url_with_branch m.opamrepo m.opamrepo_branch) 56 + { handle; name; monorepo; monorepo_branch; opamrepo; opamrepo_branch }) 57 + |> mem "handle" string ~enc:(fun (m : member) -> m.handle) 58 + |> opt_mem "name" string ~enc:(fun (m : member) -> m.name) 59 + |> mem "monorepo" string ~enc:(fun (m : member) -> encode_url_with_branch m.monorepo m.monorepo_branch) 60 + |> mem "opamrepo" string ~enc:(fun (m : member) -> encode_url_with_branch m.opamrepo m.opamrepo_branch) 57 61 |> finish)) 58 62 59 - type registry_info = { r_name : string } 63 + type registry_info = { r_name : string; r_description : string option } 60 64 61 65 let registry_info_codec : registry_info Tomlt.t = 62 66 Tomlt.( 63 67 Table.( 64 - obj (fun r_name -> { r_name }) 68 + obj (fun r_name r_description -> { r_name; r_description }) 65 69 |> mem "name" string ~enc:(fun r -> r.r_name) 70 + |> opt_mem "description" string ~enc:(fun r -> r.r_description) 66 71 |> finish)) 67 72 68 73 let codec : t Tomlt.t = 69 74 Tomlt.( 70 75 Table.( 71 76 obj (fun registry members -> 72 - { name = registry.r_name; members = Option.value ~default:[] members }) 73 - |> mem "registry" registry_info_codec ~enc:(fun t -> { r_name = t.name }) 77 + { name = registry.r_name; description = registry.r_description; members = Option.value ~default:[] members }) 78 + |> mem "registry" registry_info_codec ~enc:(fun t -> { r_name = t.name; r_description = t.description }) 74 79 |> opt_mem "members" (list member_codec) ~enc:(fun t -> 75 80 match t.members with [] -> None | ms -> Some ms) 76 81 |> finish)) 77 82 78 - let empty_registry = { name = "opamverse"; members = [] } 83 + let empty_registry = { name = "opamverse"; description = None; members = [] } 79 84 80 85 let load ~fs path = 81 86 let path_str = Fpath.to_string path in
+2
lib/verse_registry.mli
··· 7 7 8 8 type member = { 9 9 handle : string; (** Tangled handle (e.g., "alice.bsky.social") *) 10 + name : string option; (** Display name (e.g., "Alice Smith") *) 10 11 monorepo : string; (** Git URL of the member's monorepo *) 11 12 monorepo_branch : string option; (** Optional branch for monorepo (from URL#branch) *) 12 13 opamrepo : string; (** Git URL of the member's opam overlay repository *) ··· 19 20 20 21 type t = { 21 22 name : string; (** Registry name *) 23 + description : string option; (** Registry description *) 22 24 members : member list; (** List of registered members *) 23 25 } 24 26 (** The parsed registry contents. *)