···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Arod webserver - a tiny_httpd based server for Bushel content *)
77+88+open Tiny_httpd
99+open Arod.Entries
1010+1111+(** {1 Logging} *)
1212+1313+let src = Logs.Src.create "arod" ~doc:"Arod webserver"
1414+module Log = (val Logs.src_log src : Logs.LOG)
1515+1616+(** {1 Query Helpers} *)
1717+1818+let get_query_params req =
1919+ Request.query req
2020+2121+let get_query_param req name =
2222+ match List.assoc_opt name (get_query_params req) with
2323+ | Some v -> Some v
2424+ | None -> None
2525+2626+let get_query_params_multi req name =
2727+ List.filter_map (fun (k, v) ->
2828+ if k = name then Some v else None
2929+ ) (get_query_params req)
3030+3131+let get_query_info req =
3232+ let tags = get_query_params_multi req "t" |> List.map Arod.Model.Tags.of_string in
3333+ let min = match get_query_param req "min" with None -> 25 | Some v -> int_of_string v in
3434+ let show_all = match get_query_param req "all" with None -> false | Some _ -> true in
3535+ {tags; min; show_all}
3636+3737+(** {1 Response Helpers} *)
3838+3939+let html_response content =
4040+ Response.make_string ~headers:[("content-type", "text/html; charset=utf-8")] (Ok content)
4141+4242+let not_found_response = Response.fail ~code:404 "Not Found"
4343+4444+let plain_response content =
4545+ Response.make_string ~headers:[("content-type", "text/plain")] (Ok content)
4646+4747+let atom_response content =
4848+ Response.make_string ~headers:[("content-type", "application/atom+xml; charset=utf-8")] (Ok content)
4949+5050+let xml_response content =
5151+ Response.make_string ~headers:[("content-type", "application/xml")] (Ok content)
5252+5353+let json_response content =
5454+ Response.make_string ~headers:[("content-type", "application/json; charset=utf-8")] (Ok content)
5555+5656+(** {1 File Serving} *)
5757+5858+let serve_file ~dir path =
5959+ let clean_path =
6060+ let parts = String.split_on_char '/' path in
6161+ let safe_parts = List.filter (fun s -> s <> ".." && s <> ".") parts in
6262+ String.concat "/" safe_parts
6363+ in
6464+ let file_path = Filename.concat dir clean_path in
6565+ Log.info (fun m -> m "Serving file: %s (dir=%s, path=%s)" file_path dir path);
6666+ try
6767+ if Sys.file_exists file_path && not (Sys.is_directory file_path) then begin
6868+ let ic = open_in_bin file_path in
6969+ let len = in_channel_length ic in
7070+ let content = really_input_string ic len in
7171+ close_in ic;
7272+ let mime_type =
7373+ if String.ends_with ~suffix:".pdf" file_path then "application/pdf"
7474+ else if String.ends_with ~suffix:".html" file_path then "text/html"
7575+ else if String.ends_with ~suffix:".css" file_path then "text/css"
7676+ else if String.ends_with ~suffix:".js" file_path then "text/javascript"
7777+ else if String.ends_with ~suffix:".svg" file_path then "image/svg+xml"
7878+ else if String.ends_with ~suffix:".png" file_path then "image/png"
7979+ else if String.ends_with ~suffix:".jpg" file_path || String.ends_with ~suffix:".jpeg" file_path then "image/jpeg"
8080+ else if String.ends_with ~suffix:".webp" file_path then "image/webp"
8181+ else if String.ends_with ~suffix:".xml" file_path then "application/xml"
8282+ else if String.ends_with ~suffix:".wasm" file_path then "application/wasm"
8383+ else if String.ends_with ~suffix:".ico" file_path then "image/x-icon"
8484+ else if String.ends_with ~suffix:".woff" file_path then "font/woff"
8585+ else if String.ends_with ~suffix:".woff2" file_path then "font/woff2"
8686+ else if String.ends_with ~suffix:".bib" file_path then "application/x-bibtex"
8787+ else "application/octet-stream"
8888+ in
8989+ Log.info (fun m -> m "Served %s (%d bytes, %s)" file_path len mime_type);
9090+ Response.make_string ~headers:[("content-type", mime_type)] (Ok content)
9191+ end else begin
9292+ Log.warn (fun m -> m "File not found: %s" file_path);
9393+ not_found_response
9494+ end
9595+ with e ->
9696+ Log.err (fun m -> m "Failed to serve file %s: %s" file_path (Printexc.to_string e));
9797+ not_found_response
9898+9999+(** {1 HTML Output Helper} *)
100100+101101+let to_page el = Htmlit.El.to_string ~doctype:true el
102102+103103+(** {1 Entry Handlers} *)
104104+105105+let entries_handler ~extra_tags ~types req =
106106+ let q = get_query_info req in
107107+ let all_tags = Arod.Model.concat_tags q.tags (List.map Arod.Model.Tags.of_string extra_tags) in
108108+ html_response (to_page (view_entries ~show_all:q.show_all ~tags:all_tags ~min:q.min ~types (entries_of_req ~extra_tags ~types q)))
109109+110110+let feed_handler ~types req =
111111+ let q = get_query_info req in
112112+ html_response (to_page (view_news ~show_all:q.show_all ~tags:q.tags ~min:q.min ~types (feed_of_req ~types q)))
113113+114114+let feed_handler_with_tags ~extra_tags ~types req =
115115+ let q = get_query_info req in
116116+ let tags = Arod.Model.concat_tags q.tags (List.map Arod.Model.Tags.of_string extra_tags) in
117117+ let q = { q with tags } in
118118+ html_response (to_page (view_news ~show_all:q.show_all ~tags:q.tags ~min:q.min ~types (feed_of_req ~types q)))
119119+120120+let ideas_handler _req =
121121+ html_response (to_page (Arod.Ideas.view_ideas_by_project ()))
122122+123123+let projects_handler _req =
124124+ html_response (to_page (Arod.Projects.view_projects_timeline ()))
125125+126126+let index_handler req =
127127+ let q = get_query_info req in
128128+ match Arod.Model.lookup "index" with
129129+ | None -> not_found_response
130130+ | Some ent -> html_response (to_page (view_one q ent))
131131+132132+(** {1 Content Handlers} *)
133133+134134+let paper_handler cfg slug _req =
135135+ let q = get_query_info _req in
136136+ match slug with
137137+ | slug when String.ends_with ~suffix:".pdf" slug ->
138138+ serve_file ~dir:cfg.Arod.Config.paths.static_dir ("papers/" ^ slug)
139139+ | slug when String.ends_with ~suffix:".bib" slug ->
140140+ let paper_slug = Filename.chop_extension slug in
141141+ (match Arod.Model.lookup paper_slug with
142142+ | Some (`Paper p) -> plain_response (Arod.Model.Paper.bib p)
143143+ | _ -> not_found_response)
144144+ | _ ->
145145+ match Arod.Model.lookup slug with
146146+ | None -> not_found_response
147147+ | Some ent -> html_response (to_page (view_one q ent))
148148+149149+let content_handler slug req =
150150+ let q = get_query_info req in
151151+ match Arod.Model.lookup slug with
152152+ | None -> not_found_response
153153+ | Some ent -> html_response (to_page (view_one q ent))
154154+155155+let news_redirect_handler slug _req =
156156+ Response.make_raw ~code:301
157157+ ~headers:[("Location", "/notes/" ^ slug)]
158158+ "Moved Permanently"
159159+160160+(** {1 Feed Handlers} *)
161161+162162+let atom_uri req =
163163+ let path = Request.path req in
164164+ let query = Request.query req in
165165+ if query = [] then path
166166+ else
167167+ let query_string = String.concat "&" (List.map (fun (k,v) -> k ^ "=" ^ v) query) in
168168+ path ^ "?" ^ query_string
169169+170170+let atom_handler cfg req =
171171+ try
172172+ let q = get_query_info req in
173173+ let feed = feed_of_req ~types:[] q in
174174+ let ur = atom_uri req in
175175+ let s = Arod.Feed.feed_string cfg ur feed in
176176+ atom_response s
177177+ with exn -> Printexc.print_backtrace stdout; raise exn
178178+179179+let perma_atom_handler cfg _req =
180180+ try
181181+ let feed = perma_feed_of_req () in
182182+ let s = Arod.Feed.feed_string cfg "/perma.xml" feed in
183183+ atom_response s
184184+ with exn -> Printexc.print_backtrace stdout; raise exn
185185+186186+let jsonfeed_handler cfg req =
187187+ try
188188+ let q = get_query_info req in
189189+ let feed = feed_of_req ~types:[] q in
190190+ let s = Arod.Jsonfeed.feed_string cfg "/feed.json" feed in
191191+ json_response s
192192+ with exn -> Printexc.print_backtrace stdout; raise exn
193193+194194+let perma_jsonfeed_handler cfg _req =
195195+ try
196196+ let feed = perma_feed_of_req () in
197197+ let s = Arod.Jsonfeed.feed_string cfg "/perma.json" feed in
198198+ json_response s
199199+ with exn -> Printexc.print_backtrace stdout; raise exn
200200+201201+(** {1 Sitemap Handler} *)
202202+203203+let sitemap_handler cfg _req =
204204+ let all_feed = Arod.Model.all_entries ()
205205+ |> List.sort Arod.Model.Entry.compare
206206+ |> List.rev in
207207+ let url_of_entry ent =
208208+ let lastmod = Arod.Model.Entry.date ent in
209209+ let loc = cfg.Arod.Config.site.base_url ^ Arod.Model.Entry.site_url ent in
210210+ Sitemap.v ~lastmod loc
211211+ in
212212+ let sitemap = List.map url_of_entry all_feed |> Sitemap.output in
213213+ xml_response sitemap
214214+215215+(** {1 Bushel Graph Handlers} *)
216216+217217+let bushel_graph_data_handler _req =
218218+ let entries = Arod.Model.get_entries () in
219219+ match Bushel.Link_graph.get_graph () with
220220+ | None ->
221221+ json_response "{\"error\": \"Link graph not initialized\"}"
222222+ | Some graph ->
223223+ let json = Bushel.Link_graph.to_json graph entries in
224224+ json_response (Ezjsonm.value_to_string json)
225225+226226+let bushel_graph_handler _req =
227227+ html_response (to_page (Arod.Page.bushel_graph ()))
228228+229229+(** {1 Pagination API Handler} *)
230230+231231+let pagination_api_handler req =
232232+ try
233233+ let collection_type = match get_query_param req "collection" with
234234+ | Some t -> t
235235+ | None -> failwith "Missing collection parameter"
236236+ in
237237+ let offset = match get_query_param req "offset" with
238238+ | Some o -> int_of_string o
239239+ | None -> 0
240240+ in
241241+ let limit = match get_query_param req "limit" with
242242+ | Some l -> int_of_string l
243243+ | None -> 25
244244+ in
245245+ let type_strings = get_query_params_multi req "type" in
246246+ let types = List.filter_map entry_type_of_string type_strings in
247247+ let q = get_query_info req in
248248+249249+ let html = match collection_type with
250250+ | "feed" ->
251251+ let all_feed = feed_of_req ~types q in
252252+ let total = List.length all_feed in
253253+ let feed_slice =
254254+ all_feed
255255+ |> (fun l -> List.filteri (fun i _ -> i >= offset) l)
256256+ |> (fun l -> List.filteri (fun i _ -> i < limit) l)
257257+ in
258258+ let has_more = (offset + List.length feed_slice) < total in
259259+ (render_feeds_html feed_slice, total, has_more)
260260+ | "entries" ->
261261+ let all_ents = entries_of_req ~extra_tags:[] ~types q in
262262+ let total = List.length all_ents in
263263+ let ents_slice =
264264+ all_ents
265265+ |> (fun l -> List.filteri (fun i _ -> i >= offset) l)
266266+ |> (fun l -> List.filteri (fun i _ -> i < limit) l)
267267+ in
268268+ let has_more = (offset + List.length ents_slice) < total in
269269+ (render_entries_html ents_slice, total, has_more)
270270+ | _ -> failwith "Invalid collection type"
271271+ in
272272+ let rendered_html, total, has_more = html in
273273+274274+ let json = `O [
275275+ ("html", `String rendered_html);
276276+ ("total", `Float (float_of_int total));
277277+ ("offset", `Float (float_of_int offset));
278278+ ("limit", `Float (float_of_int limit));
279279+ ("has_more", `Bool has_more);
280280+ ] in
281281+ json_response (Ezjsonm.to_string json)
282282+ with e ->
283283+ let error_json = `O [("error", `String (Printexc.to_string e))] in
284284+ json_response (Ezjsonm.to_string error_json)
285285+286286+(** {1 Well-Known Handler} *)
287287+288288+let well_known_handler cfg key _req =
289289+ match List.find_opt (fun e -> e.Arod.Config.key = key) cfg.Arod.Config.well_known with
290290+ | Some entry -> plain_response entry.value
291291+ | None -> not_found_response
292292+293293+(** {1 Server Setup} *)
294294+295295+let setup_routes server cfg =
296296+ let open Route in
297297+298298+ (* Index routes *)
299299+ Server.add_route_handler ~meth:`GET server (exact_path "/" return) index_handler;
300300+ Server.add_route_handler ~meth:`GET server (exact_path "/about" return) index_handler;
301301+ Server.add_route_handler ~meth:`GET server (exact_path "/about/" return) index_handler;
302302+303303+ (* Atom feeds *)
304304+ Server.add_route_handler ~meth:`GET server (exact_path "/wiki.xml" return) (atom_handler cfg);
305305+ Server.add_route_handler ~meth:`GET server (exact_path "/news.xml" return) (atom_handler cfg);
306306+ Server.add_route_handler ~meth:`GET server (exact_path "/feeds/atom.xml" return) (atom_handler cfg);
307307+ Server.add_route_handler ~meth:`GET server (exact_path "/notes/atom.xml" return) (atom_handler cfg);
308308+ Server.add_route_handler ~meth:`GET server (exact_path "/perma.xml" return) (perma_atom_handler cfg);
309309+310310+ (* JSON feeds *)
311311+ Server.add_route_handler ~meth:`GET server (exact_path "/feed.json" return) (jsonfeed_handler cfg);
312312+ Server.add_route_handler ~meth:`GET server (exact_path "/feeds/feed.json" return) (jsonfeed_handler cfg);
313313+ Server.add_route_handler ~meth:`GET server (exact_path "/notes/feed.json" return) (jsonfeed_handler cfg);
314314+ Server.add_route_handler ~meth:`GET server (exact_path "/perma.json" return) (perma_jsonfeed_handler cfg);
315315+316316+ (* Sitemap *)
317317+ Server.add_route_handler ~meth:`GET server (exact_path "/sitemap.xml" return) (sitemap_handler cfg);
318318+319319+ (* Papers *)
320320+ Server.add_route_handler ~meth:`GET server (exact "papers" @/ string @/ return) (paper_handler cfg);
321321+ Server.add_route_handler ~meth:`GET server (exact "papers" @/ string @/ exact "" @/ return) (paper_handler cfg);
322322+ Server.add_route_handler ~meth:`GET server (exact_path "/papers" return) (entries_handler ~extra_tags:[] ~types:[`Paper]);
323323+ Server.add_route_handler ~meth:`GET server (exact_path "/papers/" return) (entries_handler ~extra_tags:[] ~types:[`Paper]);
324324+325325+ (* Ideas *)
326326+ Server.add_route_handler ~meth:`GET server (exact "ideas" @/ string @/ return) content_handler;
327327+ Server.add_route_handler ~meth:`GET server (exact "ideas" @/ string @/ exact "" @/ return) content_handler;
328328+ Server.add_route_handler ~meth:`GET server (exact_path "/ideas" return) ideas_handler;
329329+ Server.add_route_handler ~meth:`GET server (exact_path "/ideas/" return) ideas_handler;
330330+331331+ (* Notes *)
332332+ Server.add_route_handler ~meth:`GET server (exact "notes" @/ string @/ return) content_handler;
333333+ Server.add_route_handler ~meth:`GET server (exact "notes" @/ string @/ exact "" @/ return) content_handler;
334334+ Server.add_route_handler ~meth:`GET server (exact_path "/notes" return) (feed_handler_with_tags ~extra_tags:[] ~types:[`Note]);
335335+ Server.add_route_handler ~meth:`GET server (exact_path "/notes/" return) (feed_handler_with_tags ~extra_tags:[] ~types:[`Note]);
336336+337337+ (* Videos/Talks *)
338338+ Server.add_route_handler ~meth:`GET server (exact "videos" @/ string @/ return) content_handler;
339339+ Server.add_route_handler ~meth:`GET server (exact "videos" @/ string @/ exact "" @/ return) content_handler;
340340+ Server.add_route_handler ~meth:`GET server (exact_path "/talks" return) (feed_handler_with_tags ~extra_tags:[] ~types:[`Video]);
341341+ Server.add_route_handler ~meth:`GET server (exact_path "/talks/" return) (feed_handler_with_tags ~extra_tags:[] ~types:[`Video]);
342342+ Server.add_route_handler ~meth:`GET server (exact_path "/videos" return) (feed_handler_with_tags ~extra_tags:[] ~types:[`Video]);
343343+ Server.add_route_handler ~meth:`GET server (exact_path "/videos/" return) (feed_handler_with_tags ~extra_tags:[] ~types:[`Video]);
344344+345345+ (* Projects *)
346346+ Server.add_route_handler ~meth:`GET server (exact "projects" @/ string @/ return) content_handler;
347347+ Server.add_route_handler ~meth:`GET server (exact "projects" @/ string @/ exact "" @/ return) content_handler;
348348+ Server.add_route_handler ~meth:`GET server (exact_path "/projects" return) projects_handler;
349349+ Server.add_route_handler ~meth:`GET server (exact_path "/projects/" return) projects_handler;
350350+351351+ (* Legacy news redirect *)
352352+ Server.add_route_handler ~meth:`GET server (exact "news" @/ string @/ return) news_redirect_handler;
353353+354354+ (* Wiki/News legacy *)
355355+ Server.add_route_handler ~meth:`GET server (exact_path "/wiki" return) (entries_handler ~extra_tags:[] ~types:[`Paper; `Note; `Video; `Idea; `Project]);
356356+ Server.add_route_handler ~meth:`GET server (exact_path "/news" return) (feed_handler ~types:[`Note]);
357357+358358+ (* Pagination API *)
359359+ Server.add_route_handler ~meth:`GET server (exact_path "/api/entries" return) pagination_api_handler;
360360+361361+ (* Bushel link graph *)
362362+ Server.add_route_handler ~meth:`GET server (exact_path "/bushel" return) bushel_graph_handler;
363363+ Server.add_route_handler ~meth:`GET server (exact_path "/bushel/" return) bushel_graph_handler;
364364+ Server.add_route_handler ~meth:`GET server (exact_path "/bushel/graph.json" return) bushel_graph_data_handler;
365365+366366+ (* Well-known endpoints *)
367367+ Server.add_route_handler ~meth:`GET server (exact ".well-known" @/ string @/ return) (well_known_handler cfg);
368368+369369+ (* Robots.txt *)
370370+ Server.add_route_handler ~meth:`GET server (exact_path "/robots.txt" return)
371371+ (fun _req -> serve_file ~dir:cfg.paths.assets_dir "robots.txt");
372372+373373+ (* Static files *)
374374+ Server.add_route_handler ~meth:`GET server (exact "assets" @/ rest_of_path)
375375+ (fun path _req -> serve_file ~dir:cfg.paths.assets_dir path);
376376+ Server.add_route_handler ~meth:`GET server (exact "images" @/ rest_of_path)
377377+ (fun path _req -> serve_file ~dir:cfg.paths.images_dir path);
378378+ Server.add_route_handler ~meth:`GET server (exact "static" @/ rest_of_path)
379379+ (fun path _req -> serve_file ~dir:cfg.paths.static_dir path);
380380+381381+ ()
382382+383383+(** {1 CLI} *)
384384+385385+open Cmdliner
386386+387387+let setup_logging style_renderer level =
388388+ Fmt_tty.setup_std_outputs ?style_renderer ();
389389+ Logs.set_level level;
390390+ Logs.set_reporter (Logs_fmt.reporter ())
391391+392392+let logging_t =
393393+ let open Cmdliner in
394394+ Term.(const setup_logging $ Fmt_cli.style_renderer () $ Logs_cli.level ())
395395+396396+let config_file =
397397+ let doc = "Path to config file (default: ~/.config/arod/config.toml)." in
398398+ Arg.(value & opt (some file) None & info ["c"; "config"] ~docv:"FILE" ~doc)
399399+400400+let serve_cmd =
401401+ let run () config_file =
402402+ let cfg = Arod.Config.load_or_default ?path:config_file () in
403403+ Log.info (fun m -> m "Starting Arod server...");
404404+ Log.info (fun m -> m "Config:@.%a" Arod.Config.pp cfg);
405405+406406+ Eio_main.run @@ fun env ->
407407+ let fs = Eio.Stdenv.fs env in
408408+409409+ Log.info (fun m -> m "Loading entries from %s" cfg.paths.data_dir);
410410+ let _entries = Arod.Model.init ~cfg fs in
411411+ Log.info (fun m -> m "Loaded %d notes, %d papers, %d projects, %d ideas, %d videos, %d images"
412412+ (List.length (Arod.Model.notes ()))
413413+ (List.length (Arod.Model.papers ()))
414414+ (List.length (Arod.Model.projects ()))
415415+ (List.length (Arod.Model.ideas ()))
416416+ (List.length (Arod.Model.videos ()))
417417+ (List.length (Arod.Model.images ())));
418418+419419+ let server = Tiny_httpd.create ~addr:cfg.server.host ~port:cfg.server.port () in
420420+421421+ Tiny_httpd.add_middleware server ~stage:(`Stage 1) (fun h req ->
422422+ let start_time = Unix.gettimeofday () in
423423+ let resp = h req in
424424+ let elapsed = Unix.gettimeofday () -. start_time in
425425+ Log.info (fun m -> m "%s %s - %.3fs"
426426+ (Meth.to_string (Request.meth req))
427427+ (Request.path req)
428428+ elapsed);
429429+ resp
430430+ );
431431+432432+ setup_routes server cfg;
433433+434434+ Log.app (fun m -> m "Listening on http://%s:%d" cfg.server.host cfg.server.port);
435435+ match Tiny_httpd.run server with
436436+ | Ok () -> 0
437437+ | Error e ->
438438+ Log.err (fun m -> m "Server error: %s" (Printexc.to_string e));
439439+ 1
440440+ in
441441+ let doc = "Start the Arod webserver." in
442442+ let info = Cmd.info "serve" ~doc in
443443+ Cmd.v info Term.(const run $ logging_t $ config_file)
444444+445445+let init_cmd =
446446+ let run () =
447447+ let path = Arod.Config.config_file () in
448448+ let dir = Filename.dirname path in
449449+ if not (Sys.file_exists dir) then
450450+ Unix.mkdir dir 0o755;
451451+ if Sys.file_exists path then begin
452452+ Printf.eprintf "Config file already exists: %s\n" path;
453453+ 1
454454+ end else begin
455455+ let oc = open_out path in
456456+ output_string oc Arod.Config.sample_config;
457457+ close_out oc;
458458+ Printf.printf "Created config file: %s\n" path;
459459+ 0
460460+ end
461461+ in
462462+ let doc = "Initialize a default configuration file." in
463463+ let info = Cmd.info "init" ~doc in
464464+ Cmd.v info Term.(const run $ const ())
465465+466466+let config_cmd =
467467+ let run () config_file =
468468+ let cfg = Arod.Config.load_or_default ?path:config_file () in
469469+ Fmt.pr "%a\n" Arod.Config.pp cfg;
470470+ 0
471471+ in
472472+ let doc = "Show current configuration." in
473473+ let info = Cmd.info "config" ~doc in
474474+ Cmd.v info Term.(const run $ logging_t $ config_file)
475475+476476+let main_cmd =
477477+ let doc = "Arod - a webserver for Bushel content" in
478478+ let man = [
479479+ `S Manpage.s_description;
480480+ `P "Arod is a tiny_httpd-based webserver that serves Bushel content \
481481+ (notes, papers, projects, ideas, videos) as a website.";
482482+ `S "CONFIGURATION";
483483+ `P "Configuration is read from ~/.config/arod/config.toml";
484484+ `P "Run $(b,arod init) to create a default config file.";
485485+ ] in
486486+ let info = Cmd.info "arod" ~version:"0.1.0" ~doc ~man in
487487+ Cmd.group info [serve_cmd; init_cmd; config_cmd]
488488+489489+let () =
490490+ match Cmd.eval_value main_cmd with
491491+ | Ok (`Ok exit_code) -> exit exit_code
492492+ | Ok `Help | Ok `Version -> exit 0
493493+ | Error _ -> exit 1
+34
arod/dune-project
···11+(lang dune 3.18)
22+(name arod)
33+44+(generate_opam_files true)
55+(maintenance_intent "(latest)")
66+77+(license ISC)
88+(authors "Anil Madhavapeddy <anil@recoil.org>")
99+(maintainers "Anil Madhavapeddy <anil@recoil.org>")
1010+1111+(package
1212+ (name arod)
1313+ (synopsis "Webserver for Bushel content")
1414+ (description
1515+ "Arod is a tiny_httpd-based webserver that serves Bushel content
1616+ (notes, papers, projects, ideas, videos) as a website. It uses
1717+ TOML configuration for easy deployment and includes support for
1818+ responsive images, syntax highlighting, and feeds.")
1919+ (depends
2020+ (ocaml (>= 5.2))
2121+ (bushel (>= 0.1))
2222+ (tiny_httpd (>= 0.17))
2323+ (htmlit (>= 0.1))
2424+ (cmarkit (>= 0.3))
2525+ (uri (>= 4.4))
2626+ (ptime (>= 1.2))
2727+ (fmt (>= 0.9))
2828+ (tomlt (>= 0.1))
2929+ (eio (>= 1.2))
3030+ eio_main
3131+ cmdliner
3232+ logs
3333+ unix
3434+ (odoc :with-doc)))
+62
arod/lib/arod.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Arod - Webserver for Bushel content
77+88+ Arod is a tiny_httpd-based webserver that serves Bushel content
99+ (notes, papers, projects, ideas, videos) as a website.
1010+1111+ {1 Core Modules}
1212+1313+ - {!Config} - TOML configuration
1414+ - {!Model} - Bushel bridge layer
1515+ - {!View} - Core rendering utilities
1616+ - {!Page} - Page layout
1717+ - {!Entries} - Entry type filtering and rendering *)
1818+1919+module Config = Arod_config
2020+(** TOML-based configuration for the webserver. *)
2121+2222+module Model = Arod_model
2323+(** Model layer bridging Bushel to the webserver. *)
2424+2525+module View = Arod_view
2626+(** Core view rendering utilities. *)
2727+2828+module Page = Arod_page
2929+(** Page layout. *)
3030+3131+module Footer = Arod_footer
3232+(** Standard footer. *)
3333+3434+module Notes = Arod_notes
3535+(** Note rendering. *)
3636+3737+module Papers = Arod_papers
3838+(** Paper rendering. *)
3939+4040+module Ideas = Arod_ideas
4141+(** Idea rendering. *)
4242+4343+module Projects = Arod_projects
4444+(** Project rendering. *)
4545+4646+module Videos = Arod_videos
4747+(** Video rendering. *)
4848+4949+module Entries = Arod_entries
5050+(** Entry type filtering and rendering. *)
5151+5252+module Feed = Arod_feed
5353+(** Atom feed generation. *)
5454+5555+module Jsonfeed = Arod_jsonfeed
5656+(** JSON feed generation. *)
5757+5858+module Richdata = Arod_richdata
5959+(** JSON-LD rich data for SEO. *)
6060+6161+module Html = Arod_html
6262+(** Legacy HTML generation (for compatibility). *)
+244
arod/lib/arod_config.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Configuration for the Arod webserver *)
77+88+type server = {
99+ host : string;
1010+ port : int;
1111+}
1212+1313+type paths = {
1414+ data_dir : string;
1515+ assets_dir : string;
1616+ images_dir : string;
1717+ static_dir : string;
1818+}
1919+2020+type site = {
2121+ base_url : string;
2222+ name : string;
2323+ description : string;
2424+ author_handle : string;
2525+ author_name : string;
2626+ author_email : string option;
2727+ author_orcid : string option;
2828+}
2929+3030+type feeds = {
3131+ title : string;
3232+ subtitle : string option;
3333+}
3434+3535+type well_known_entry = {
3636+ key : string;
3737+ value : string;
3838+}
3939+4040+type t = {
4141+ server : server;
4242+ paths : paths;
4343+ site : site;
4444+ feeds : feeds;
4545+ well_known : well_known_entry list;
4646+}
4747+4848+(** Path expansion helper - expands ~ to home directory *)
4949+let expand_path p =
5050+ if String.length p > 0 && p.[0] = '~' then
5151+ let home = Sys.getenv_opt "HOME" |> Option.value ~default:"/tmp" in
5252+ home ^ String.sub p 1 (String.length p - 1)
5353+ else p
5454+5555+(** Default configuration *)
5656+let default =
5757+ let home = Sys.getenv_opt "HOME" |> Option.value ~default:"/tmp" in
5858+ {
5959+ server = {
6060+ host = "0.0.0.0";
6161+ port = 8080;
6262+ };
6363+ paths = {
6464+ data_dir = Filename.concat home "bushel";
6565+ assets_dir = "./assets";
6666+ images_dir = Filename.concat home "bushel/images/web";
6767+ static_dir = "./static";
6868+ };
6969+ site = {
7070+ base_url = "http://localhost:8080";
7171+ name = "My Site";
7272+ description = "A personal website powered by Bushel";
7373+ author_handle = "me";
7474+ author_name = "Site Author";
7575+ author_email = None;
7676+ author_orcid = None;
7777+ };
7878+ feeds = {
7979+ title = "Site Feed";
8080+ subtitle = None;
8181+ };
8282+ well_known = [];
8383+ }
8484+8585+(** {1 TOML Codecs} *)
8686+8787+(** String codec with path expansion *)
8888+let path_string =
8989+ Tomlt.(map string ~dec:expand_path)
9090+9191+let server_codec =
9292+ Tomlt.(Table.(
9393+ obj (fun host port -> { host; port })
9494+ |> mem "host" string ~dec_absent:default.server.host ~enc:(fun s -> s.host)
9595+ |> mem "port" int ~dec_absent:default.server.port ~enc:(fun s -> s.port)
9696+ |> finish
9797+ ))
9898+9999+let paths_codec =
100100+ Tomlt.(Table.(
101101+ obj (fun data_dir assets_dir images_dir static_dir ->
102102+ { data_dir; assets_dir; images_dir; static_dir })
103103+ |> mem "data_dir" path_string ~dec_absent:default.paths.data_dir ~enc:(fun p -> p.data_dir)
104104+ |> mem "assets_dir" path_string ~dec_absent:default.paths.assets_dir ~enc:(fun p -> p.assets_dir)
105105+ |> mem "images_dir" path_string ~dec_absent:default.paths.images_dir ~enc:(fun p -> p.images_dir)
106106+ |> mem "static_dir" path_string ~dec_absent:default.paths.static_dir ~enc:(fun p -> p.static_dir)
107107+ |> finish
108108+ ))
109109+110110+let site_codec =
111111+ Tomlt.(Table.(
112112+ obj (fun base_url name description author_handle author_name author_email author_orcid ->
113113+ { base_url; name; description; author_handle; author_name; author_email; author_orcid })
114114+ |> mem "base_url" string ~dec_absent:default.site.base_url ~enc:(fun s -> s.base_url)
115115+ |> mem "name" string ~dec_absent:default.site.name ~enc:(fun s -> s.name)
116116+ |> mem "description" string ~dec_absent:default.site.description ~enc:(fun s -> s.description)
117117+ |> mem "author_handle" string ~dec_absent:default.site.author_handle ~enc:(fun s -> s.author_handle)
118118+ |> mem "author_name" string ~dec_absent:default.site.author_name ~enc:(fun s -> s.author_name)
119119+ |> opt_mem "author_email" string ~enc:(fun s -> s.author_email)
120120+ |> opt_mem "author_orcid" string ~enc:(fun s -> s.author_orcid)
121121+ |> finish
122122+ ))
123123+124124+let feeds_codec =
125125+ Tomlt.(Table.(
126126+ obj (fun title subtitle -> { title; subtitle })
127127+ |> mem "title" string ~dec_absent:default.feeds.title ~enc:(fun f -> f.title)
128128+ |> opt_mem "subtitle" string ~enc:(fun f -> f.subtitle)
129129+ |> finish
130130+ ))
131131+132132+let well_known_entry_codec =
133133+ Tomlt.(Table.(
134134+ obj (fun key value -> { key; value })
135135+ |> mem "key" string ~enc:(fun e -> e.key)
136136+ |> mem "value" string ~enc:(fun e -> e.value)
137137+ |> finish
138138+ ))
139139+140140+(** Codec for well_known as a table of key-value pairs *)
141141+let well_known_codec =
142142+ Tomlt.(Table.(
143143+ keep_unknown
144144+ ~enc:(fun wk -> List.map (fun e -> (e.key, e.value)) wk)
145145+ (Mems.assoc string)
146146+ (obj (fun assoc -> List.map (fun (key, value) -> { key; value }) assoc))
147147+ |> finish
148148+ ))
149149+150150+let config_codec =
151151+ Tomlt.(Table.(
152152+ obj (fun server paths site feeds well_known ->
153153+ { server; paths; site; feeds; well_known })
154154+ |> mem "server" server_codec ~dec_absent:default.server ~enc:(fun c -> c.server)
155155+ |> mem "paths" paths_codec ~dec_absent:default.paths ~enc:(fun c -> c.paths)
156156+ |> mem "site" site_codec ~dec_absent:default.site ~enc:(fun c -> c.site)
157157+ |> mem "feeds" feeds_codec ~dec_absent:default.feeds ~enc:(fun c -> c.feeds)
158158+ |> mem "well_known" well_known_codec ~dec_absent:[] ~enc:(fun c -> c.well_known)
159159+ |> finish
160160+ ))
161161+162162+let of_toml_string s =
163163+ match Tomlt_bytesrw.decode_string config_codec s with
164164+ | Ok cfg -> cfg
165165+ | Error e -> failwith (Tomlt.Error.to_string e)
166166+167167+let of_file path =
168168+ let ic = open_in path in
169169+ let content = really_input_string ic (in_channel_length ic) in
170170+ close_in ic;
171171+ of_toml_string content
172172+173173+let config_file () =
174174+ let xdg_config = Sys.getenv_opt "XDG_CONFIG_HOME" in
175175+ let home = Sys.getenv_opt "HOME" in
176176+ match xdg_config, home with
177177+ | Some xdg, _ -> Filename.concat xdg "arod/config.toml"
178178+ | None, Some h -> Filename.concat h ".config/arod/config.toml"
179179+ | None, None -> "./config.toml"
180180+181181+let load_or_default ?path () =
182182+ let path = match path with
183183+ | Some p -> p
184184+ | None -> config_file ()
185185+ in
186186+ if Sys.file_exists path then
187187+ of_file path
188188+ else
189189+ default
190190+191191+(** {1 Sample Config Generation} *)
192192+193193+let sample_config = {|# Arod Webserver Configuration
194194+195195+[server]
196196+host = "0.0.0.0"
197197+port = 8080
198198+199199+[paths]
200200+# Bushel data directory (notes, papers, projects, etc.)
201201+data_dir = "~/bushel"
202202+# Static assets (CSS, JS, icons)
203203+assets_dir = "./assets"
204204+# Processed images from srcsetter
205205+images_dir = "~/bushel/images/web"
206206+# Static files (PDFs, etc.)
207207+static_dir = "./static"
208208+209209+[site]
210210+base_url = "https://example.com"
211211+name = "My Site"
212212+description = "A personal website powered by Bushel"
213213+author_handle = "me"
214214+author_name = "Your Name"
215215+# author_email = "you@example.com"
216216+# author_orcid = "0000-0000-0000-0000"
217217+218218+[feeds]
219219+title = "Site Feed"
220220+# subtitle = "Latest posts and updates"
221221+222222+# Optional: well-known endpoints for AT Protocol, etc.
223223+# [well_known]
224224+# "site.standard.publication" = "at://did:plc:example/app.bsky.feed.post/id"
225225+|}
226226+227227+(** {1 Pretty Printing} *)
228228+229229+let pp ppf t =
230230+ let open Fmt in
231231+ pf ppf "@[<v>";
232232+ pf ppf "Server:@,";
233233+ pf ppf " host: %s@," t.server.host;
234234+ pf ppf " port: %d@," t.server.port;
235235+ pf ppf "@,Paths:@,";
236236+ pf ppf " data_dir: %s@," t.paths.data_dir;
237237+ pf ppf " assets_dir: %s@," t.paths.assets_dir;
238238+ pf ppf " images_dir: %s@," t.paths.images_dir;
239239+ pf ppf " static_dir: %s@," t.paths.static_dir;
240240+ pf ppf "@,Site:@,";
241241+ pf ppf " base_url: %s@," t.site.base_url;
242242+ pf ppf " name: %s@," t.site.name;
243243+ pf ppf " author: %s (@%s)@," t.site.author_name t.site.author_handle;
244244+ pf ppf "@]"
+406
arod/lib/arod_entries.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Entry type filtering and rendering for Arod webserver *)
77+88+open Htmlit
99+1010+(** Entry type filter *)
1111+type entry_type = [ `Paper | `Note | `Video | `Idea | `Project ]
1212+1313+let entry_type_to_string = function
1414+ | `Paper -> "paper"
1515+ | `Note -> "note"
1616+ | `Video -> "video"
1717+ | `Idea -> "idea"
1818+ | `Project -> "project"
1919+2020+let entry_type_of_string = function
2121+ | "paper" -> Some `Paper
2222+ | "note" -> Some `Note
2323+ | "video" -> Some `Video
2424+ | "idea" -> Some `Idea
2525+ | "project" -> Some `Project
2626+ | _ -> None
2727+2828+(** Helper functions for common attributes *)
2929+let class_ c = At.class' c
3030+let href h = At.href h
3131+3232+let render_entry (ent:Arod_model.Entry.entry) =
3333+ let (t, _word_count_info) = match ent with
3434+ | `Paper p -> Arod_papers.paper_for_entry p
3535+ | `Note n -> Arod_notes.one_note_brief n
3636+ | `Video v -> Arod_videos.one_video v
3737+ | `Idea i -> Arod_ideas.one_idea_brief i
3838+ | `Project p -> Arod_projects.one_project_brief p
3939+ in
4040+ El.splice [t; Arod_view.tags_meta ent]
4141+4242+let render_entry_for_feed ent =
4343+ match ent with
4444+ | `Paper p -> fst (Arod_papers.paper_for_feed p)
4545+ | `Note n -> fst (Arod_notes.note_for_feed n)
4646+ | `Video v -> fst (Arod_videos.video_for_feed v)
4747+ | `Idea i -> fst (Arod_ideas.idea_for_feed i)
4848+ | `Project p -> fst (Arod_projects.project_for_feed p)
4949+5050+let render_feed ent =
5151+ let (entry_html, _word_count_info) = match ent with
5252+ | `Paper p -> Arod_papers.paper_for_feed p
5353+ | `Note n -> Arod_notes.note_for_feed n
5454+ | `Video v -> Arod_videos.video_for_feed v
5555+ | `Idea i -> Arod_ideas.idea_for_feed i
5656+ | `Project p -> Arod_projects.project_for_feed p
5757+ in
5858+ El.splice [
5959+ Arod_view.entry_href ent;
6060+ entry_html;
6161+ Arod_view.tags_meta ent
6262+ ]
6363+6464+let render_backlinks_content ent =
6565+ let slug = Arod_model.Entry.slug ent in
6666+ let entry_type = match ent with
6767+ | `Paper _ -> "paper"
6868+ | `Note _ -> "note"
6969+ | `Idea _ -> "idea"
7070+ | `Project _ -> "project"
7171+ | `Video _ -> "video"
7272+ in
7373+ let entries = Arod_model.get_entries () in
7474+ let backlink_slugs = Bushel.Link_graph.get_backlinks_for_slug slug in
7575+ if backlink_slugs = [] then
7676+ None
7777+ else
7878+ let backlink_items = List.filter_map (fun backlink_slug ->
7979+ match Arod_model.Entry.lookup entries backlink_slug with
8080+ | Some entry ->
8181+ let title = Arod_model.Entry.title entry in
8282+ let url = Arod_model.Entry.site_url entry in
8383+ Some (El.li [El.a ~at:[At.href url] [El.txt title]])
8484+ | None -> None
8585+ ) backlink_slugs in
8686+ if backlink_items = [] then
8787+ None
8888+ else
8989+ Some (El.splice [
9090+ El.span ~at:[At.class' "sidenote-number"] [El.txt "↑"];
9191+ El.span ~at:[At.class' "sidenote-icon"] [El.txt ""];
9292+ El.txt (Printf.sprintf "The following entries link to this %s: " entry_type);
9393+ El.ul backlink_items
9494+ ])
9595+9696+let render_one_entry ent =
9797+ match ent with
9898+ | `Paper p -> Arod_papers.one_paper_full p, Arod_papers.one_paper_extra p
9999+ | `Idea i -> Arod_ideas.one_idea_full i, El.splice []
100100+ | `Note n -> Arod_notes.one_note_full n, El.splice []
101101+ | `Video v -> Arod_videos.one_video_full v, El.splice []
102102+ | `Project p -> Arod_projects.one_project_full p, El.splice []
103103+104104+type query_info = {
105105+ tags: Arod_model.Tags.t list;
106106+ min: int;
107107+ show_all: bool;
108108+}
109109+110110+let sort_of_ent ent =
111111+ match ent with
112112+ | `Paper p -> (match Arod_model.Paper.bibtype p with
113113+ | "inproceedings" -> "conference paper"
114114+ | "article" | "journal" -> "journal paper"
115115+ | "misc" -> "preprint"
116116+ | "techreport" -> "technical report"
117117+ | _ -> "paper"), ""
118118+ | `Note {Arod_model.Note.updated=Some _;date=u; _} ->
119119+ "note", Printf.sprintf " (originally on %s)" (Arod_view.ptime_date ~with_d:true u)
120120+ | `Note _ -> "note", ""
121121+ | `Project _ -> "project", ""
122122+ | `Idea _ -> "research idea", ""
123123+ | `Video _ -> "video", ""
124124+125125+let footer = Arod_footer.footer
126126+127127+let take n l =
128128+ let[@tail_mod_cons] rec aux n l =
129129+ match n, l with
130130+ | 0, _ | _, [] -> []
131131+ | n, x::l -> x::aux (n - 1) l
132132+ in
133133+ if n < 0 then invalid_arg "List.take";
134134+ aux n l
135135+136136+let feed_title_link ent =
137137+ El.a ~at:[href (Arod_model.Entry.site_url ent)] [El.txt (Arod_model.Entry.title ent)]
138138+139139+let tags_heading tags =
140140+ Arod_view.map_and Arod_model.Tags.to_raw_string tags
141141+142142+let view_news ~show_all ~tags ~min:_ ~types feed =
143143+ let feed' =
144144+ match show_all, List.length feed with
145145+ | false, n when n > 25 -> take 25 feed
146146+ | false, _ -> feed
147147+ | true, _ -> feed
148148+ in
149149+ let title = "News " ^ (match tags with [] -> "" | tags -> " about " ^ (tags_heading tags)) in
150150+ let description = Printf.sprintf "Showing %d news item(s)" (List.length feed') in
151151+ let main_content =
152152+ let rec intersperse_hr = function
153153+ | [] -> []
154154+ | [x] -> [render_feed x]
155155+ | x::xs -> render_feed x :: El.hr () :: intersperse_hr xs
156156+ in
157157+ intersperse_hr feed' in
158158+ let page_footer = El.splice [footer] in
159159+ let pagination_attrs =
160160+ let tags_str = String.concat "," (List.map Arod_model.Tags.to_raw_string tags) in
161161+ let types_str = String.concat "," (List.map entry_type_to_string types) in
162162+ [
163163+ At.v "data-pagination" "true";
164164+ At.v "data-collection-type" "feed";
165165+ At.v "data-total-count" (string_of_int (List.length feed));
166166+ At.v "data-current-count" (string_of_int (List.length feed'));
167167+ At.v "data-tags" tags_str;
168168+ At.v "data-types" types_str;
169169+ ]
170170+ in
171171+ let page_content =
172172+ El.splice [
173173+ El.article ~at:pagination_attrs main_content;
174174+ El.aside []
175175+ ]
176176+ in
177177+ Arod_page.page ~title ~page_content ~page_footer ~description ()
178178+179179+let render_entries_html ents =
180180+ let rendered = List.map render_entry ents in
181181+ let rec add_separators = function
182182+ | [] -> []
183183+ | [x] -> [x]
184184+ | x :: xs -> x :: El.hr () :: add_separators xs
185185+ in
186186+ let html_elements = El.hr () :: add_separators rendered in
187187+ El.to_string ~doctype:false (El.splice html_elements)
188188+189189+let render_feeds_html feeds =
190190+ let rec intersperse_hr = function
191191+ | [] -> []
192192+ | [x] -> [render_feed x]
193193+ | x::xs -> render_feed x :: El.hr () :: intersperse_hr xs
194194+ in
195195+ let html_elements = El.hr () :: intersperse_hr feeds in
196196+ El.to_string ~doctype:false (El.splice html_elements)
197197+198198+let view_entries ~show_all ~tags ~min:_ ~types ents =
199199+ let ents' =
200200+ match show_all, List.length ents with
201201+ | false, n when n > 25 -> take 25 ents
202202+ | false, _ -> ents
203203+ | true, _ -> ents
204204+ in
205205+ let title = String.capitalize_ascii (tags_heading tags ^ (if tags <> [] then " " else "")) in
206206+ let description = Printf.sprintf "Showing %d item(s)" (List.length ents') in
207207+ let main_content =
208208+ let rendered = List.map render_entry ents' in
209209+ let rec add_separators = function
210210+ | [] -> []
211211+ | [x] -> [x]
212212+ | x :: xs -> x :: El.hr () :: add_separators xs
213213+ in
214214+ add_separators rendered
215215+ in
216216+ let page_footer = El.splice [footer] in
217217+ let pagination_attrs =
218218+ let tags_str = String.concat "," (List.map Arod_model.Tags.to_raw_string tags) in
219219+ let types_str = String.concat "," (List.map entry_type_to_string types) in
220220+ [
221221+ At.v "data-pagination" "true";
222222+ At.v "data-collection-type" "entries";
223223+ At.v "data-total-count" (string_of_int (List.length ents));
224224+ At.v "data-current-count" (string_of_int (List.length ents'));
225225+ At.v "data-tags" tags_str;
226226+ At.v "data-types" types_str;
227227+ ]
228228+ in
229229+ let page_content =
230230+ El.splice [
231231+ El.article ~at:pagination_attrs main_content;
232232+ El.aside []
233233+ ]
234234+ in
235235+ Arod_page.page ~title ~page_content ~page_footer ~description ()
236236+237237+let breadcrumbs cfg l = ("Home", cfg.Arod_config.site.base_url ^ "/") :: l
238238+239239+let view_one _q ent =
240240+ let cfg = Arod_model.get_config () in
241241+ let entries = Arod_model.get_entries () in
242242+ let title = Arod_model.Entry.title ent in
243243+ let description = match Arod_model.Entry.synopsis ent with Some v -> v | None -> "" in
244244+ let eh, extra = render_one_entry ent in
245245+ let is_index = Arod_model.Entry.is_index_entry ent in
246246+ let standardsite = match ent with
247247+ | `Note n -> Arod_model.Note.standardsite n
248248+ | _ -> None
249249+ in
250250+ let backlinks_content =
251251+ if is_index then None
252252+ else render_backlinks_content ent
253253+ in
254254+ let related_container =
255255+ match ent with
256256+ | `Project _ -> El.splice []
257257+ | _ when is_index -> El.splice []
258258+ | `Note _ ->
259259+ let tags = Arod_model.Entry.tags_of_ent entries ent in
260260+ let tag_strings = List.map Arod_model.Tags.to_raw_string tags |> String.concat " " in
261261+ El.div ~at:[
262262+ class_ "related-items";
263263+ At.v "data-entry-title" title;
264264+ At.v "data-entry-id" (Arod_model.Entry.slug ent);
265265+ At.v "data-entry-tags" tag_strings
266266+ ] []
267267+ | _ ->
268268+ let tags = Arod_model.Entry.tags_of_ent entries ent in
269269+ let tag_strings = List.map Arod_model.Tags.to_raw_string tags |> String.concat " " in
270270+ El.splice [
271271+ El.hr ();
272272+ El.div ~at:[
273273+ class_ "related-items";
274274+ At.v "data-entry-title" title;
275275+ At.v "data-entry-id" (Arod_model.Entry.slug ent);
276276+ At.v "data-entry-tags" tag_strings
277277+ ] []
278278+ ]
279279+ in
280280+ let bs = Arod_richdata.(breadcrumbs @@ breadcrumb_of_ent cfg ent) in
281281+ let jsonld = bs ^ (Arod_richdata.json_of_entry cfg ent) in
282282+ let image = match Arod_model.Entry.thumbnail entries ent with
283283+ | Some thumb -> cfg.site.base_url ^ thumb
284284+ | None -> cfg.site.base_url ^ "/assets/imagetitle-default.jpg"
285285+ in
286286+ let page_footer, page_content =
287287+ if is_index then
288288+ let page_footer = footer in
289289+ let page_content = El.splice [
290290+ El.article [eh];
291291+ El.aside []
292292+ ] in
293293+ page_footer, page_content
294294+ else
295295+ let page_footer = footer in
296296+ let references_html = match ent with
297297+ | `Note n -> El.splice [El.hr (); Arod_view.note_references_html n]
298298+ | _ -> El.splice []
299299+ in
300300+ let page_content = El.splice [
301301+ El.article [
302302+ eh;
303303+ Arod_view.tags_meta ?backlinks_content ent;
304304+ references_html;
305305+ related_container;
306306+ extra
307307+ ];
308308+ El.aside []
309309+ ] in
310310+ page_footer, page_content
311311+ in
312312+ Arod_page.page ~image ~title ~jsonld ?standardsite ~page_content ~page_footer ~description ()
313313+314314+let filter_fn query_tags item_tags =
315315+ let item_sets, item_text = List.partition (function `Set _ -> true | _ -> false) item_tags in
316316+ let query_sets, query_text = List.partition (function `Set _ -> true | _ -> false) query_tags in
317317+ let test_set seta setb =
318318+ match setb with
319319+ | [] -> true
320320+ | setb -> List.exists (fun tag -> List.mem tag seta) setb
321321+ in
322322+ (test_set item_sets query_sets) &&
323323+ (test_set item_text query_text)
324324+325325+let entry_matches_type types ent =
326326+ if types = [] then true
327327+ else
328328+ List.exists (fun typ ->
329329+ match typ, ent with
330330+ | `Paper, `Paper _ -> true
331331+ | `Note, `Note _ -> true
332332+ | `Video, `Video _ -> true
333333+ | `Idea, `Idea _ -> true
334334+ | `Project, `Project _ -> true
335335+ | _ -> false
336336+ ) types
337337+338338+let feed_of_req ~types q =
339339+ let entries = Arod_model.get_entries () in
340340+ let filterent = entry_matches_type types in
341341+ let select ent =
342342+ let only_talks = function
343343+ | `Video { Arod_model.Video.talk; _ } -> talk
344344+ | _ -> true
345345+ in
346346+ let not_index_page = function
347347+ | `Note { Arod_model.Note.index_page; _ } -> not index_page
348348+ | _ -> true
349349+ in
350350+ only_talks ent && not_index_page ent
351351+ in
352352+ let all_entries = Arod_model.all_entries () in
353353+ match q.tags with
354354+ | [] ->
355355+ all_entries
356356+ |> List.filter (fun ent -> select ent && filterent ent)
357357+ |> List.sort Arod_model.Entry.compare
358358+ |> List.rev
359359+ | t ->
360360+ all_entries
361361+ |> List.filter (fun ent ->
362362+ select ent && filterent ent && filter_fn t (Arod_model.Entry.tags_of_ent entries ent))
363363+ |> List.sort Arod_model.Entry.compare
364364+ |> List.rev
365365+366366+let perma_feed_of_req () =
367367+ let filterent ent =
368368+ match ent with
369369+ | `Note n -> Arod_model.Note.perma n
370370+ | _ -> false
371371+ in
372372+ let all_entries = Arod_model.all_entries () in
373373+ all_entries
374374+ |> List.filter filterent
375375+ |> List.sort Arod_model.Entry.compare
376376+ |> List.rev
377377+378378+let entries_of_req ~extra_tags ~types q =
379379+ let entries = Arod_model.get_entries () in
380380+ let tags = Arod_model.concat_tags q.tags (List.map Arod_model.Tags.of_string extra_tags) in
381381+ let q = { q with tags } in
382382+ let filterent = entry_matches_type types in
383383+ let select ent =
384384+ let only_talks = function
385385+ | `Video { Arod_model.Video.talk; _ } -> talk
386386+ | _ -> true
387387+ in
388388+ let not_index_page = function
389389+ | `Note { Arod_model.Note.index_page; _ } -> not index_page
390390+ | _ -> true
391391+ in
392392+ only_talks ent && not_index_page ent
393393+ in
394394+ let all_entries = Arod_model.all_entries () in
395395+ match q.tags with
396396+ | [] ->
397397+ all_entries
398398+ |> List.filter (fun ent -> select ent && filterent ent)
399399+ |> List.sort Arod_model.Entry.compare
400400+ |> List.rev
401401+ | ts ->
402402+ all_entries
403403+ |> List.filter (fun ent ->
404404+ select ent && filterent ent && filter_fn ts (Arod_model.Entry.tags_of_ent entries ent))
405405+ |> List.sort Arod_model.Entry.compare
406406+ |> List.rev
+138
arod/lib/arod_feed.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Atom feed generation for Arod webserver *)
77+88+module E = Arod_model.Entry
99+module N = Arod_model.Note
1010+module C = Sortal_schema.Contact
1111+module X = Syndic.Atom
1212+1313+let anil_copyright = "(c) 1998-2025 Anil Madhavapeddy, all rights reserved"
1414+1515+let author c =
1616+ let uri = Option.map Uri.of_string (C.best_url c) in
1717+ let email = match C.emails c with e :: _ -> Some e.C.address | [] -> None in
1818+ {X.name=(C.name c); email; uri}
1919+2020+let form_uri cfg path = Uri.of_string (cfg.Arod_config.site.base_url ^ path)
2121+2222+let atom_id cfg e = form_uri cfg @@ E.site_url e
2323+2424+let generator = {
2525+ X.version = Some "1.0";
2626+ uri = Some (Uri.of_string "https://github.com/avsm/bushel");
2727+ content = "Bushel"
2828+}
2929+3030+let link cfg e =
3131+ let href = form_uri cfg @@ E.site_url e in
3232+ let rel = X.Self in
3333+ let type_media = None in
3434+ let title = E.title e in
3535+ let length = None in
3636+ let hreflang = None in
3737+ {X.href; rel; type_media; title; length; hreflang}
3838+3939+let news_feed_link cfg =
4040+ let href = form_uri cfg "/news.xml" in
4141+ let rel = X.Self in
4242+ let type_media = None in
4343+ let title = cfg.Arod_config.site.name in
4444+ let length = None in
4545+ let hreflang = None in
4646+ {X.href; rel; type_media; title; length; hreflang}
4747+4848+let ext_link ~title l =
4949+ let href = Uri.of_string l in
5050+ let rel = X.Alternate in
5151+ let type_media = None in
5252+ let title = title in
5353+ let length = None in
5454+ let hreflang = None in
5555+ [{X.href; rel; type_media; title; length; hreflang}]
5656+5757+let atom_of_note cfg ~author note =
5858+ let e = `Note note in
5959+ let id = atom_id cfg e in
6060+ let categories = List.map (fun tag ->
6161+ X.category tag
6262+ ) (N.tags note) in
6363+ let rights : X.title = X.Text anil_copyright in
6464+ let source = None in
6565+ let title : X.title = X.Text note.N.title in
6666+ let published = N.origdate note in
6767+ let updated = N.datetime note in
6868+ let authors = author, [] in
6969+7070+ let base_html = Arod_view.md_to_atom_html note.N.body in
7171+7272+ let is_perma = N.perma note in
7373+ let has_doi = match N.doi note with Some _ -> true | None -> false in
7474+ let html_with_refs =
7575+ if is_perma || has_doi then
7676+ let me = match Arod_model.lookup_by_handle cfg.Arod_config.site.author_handle with
7777+ | Some c -> c
7878+ | None -> failwith "Author not found"
7979+ in
8080+ let references = Bushel.Md.note_references (Arod_model.get_entries ()) me note in
8181+ if List.length references > 0 then
8282+ let refs_html =
8383+ let ref_items = List.map (fun (doi, citation, _) ->
8484+ let doi_url = Printf.sprintf "https://doi.org/%s" doi in
8585+ Printf.sprintf "<li>%s<a href=\"%s\" target=\"_blank\"><i>%s</i></a></li>"
8686+ citation doi_url doi
8787+ ) references |> String.concat "\n" in
8888+ Printf.sprintf "<h1>References</h1><ul>%s</ul>" ref_items
8989+ in
9090+ base_html ^ refs_html
9191+ else
9292+ base_html
9393+ else
9494+ base_html
9595+ in
9696+9797+ let html_base_uri = Some (Uri.of_string (cfg.site.base_url ^ "/")) in
9898+ let content, links =
9999+ match N.link note with
100100+ | `Local _ ->
101101+ let content = Some (X.Html (html_base_uri, html_with_refs)) in
102102+ let links = [link cfg e] in
103103+ content, links
104104+ | `Ext (_l,u) ->
105105+ let content = Some (X.Html (html_base_uri, html_with_refs)) in
106106+ let links = ext_link ~title:note.N.title u in
107107+ content, links
108108+ in
109109+ let entry = Syndic.Atom.entry
110110+ ~categories ~links ~published ~rights ?content
111111+ ?source ~title ~updated
112112+ ~id ~authors ()
113113+ in
114114+ entry
115115+116116+let atom_of_entry cfg ~author (e:Arod_model.Entry.entry) =
117117+ match e with
118118+ | `Note n -> Some (atom_of_note cfg ~author n)
119119+ | _ -> None
120120+121121+let feed cfg uri entries =
122122+ try
123123+ let author = author @@ (Arod_model.lookup_by_handle cfg.Arod_config.site.author_handle |> Option.get) in
124124+ let authors = [author] in
125125+ let icon = Uri.of_string (cfg.site.base_url ^ "/assets/favicon.ico") in
126126+ let links = [news_feed_link cfg] in
127127+ let atom_entries = List.filter_map (atom_of_entry cfg ~author) entries in
128128+ let title : X.text_construct = X.Text (cfg.site.name ^ "'s feed") in
129129+ let updated = Arod_model.Entry.datetime (List.hd entries) in
130130+ let id = form_uri cfg uri in
131131+ let rights : X.title = X.Text anil_copyright in
132132+ X.feed ~id ~rights ~authors ~title ~updated ~icon ~links atom_entries
133133+ with exn -> Printexc.print_backtrace stdout; print_endline "x"; raise exn
134134+135135+let feed_string cfg uri f =
136136+ let buf = Buffer.create 1024 in
137137+ X.output (feed cfg uri f) (`Buffer buf);
138138+ Buffer.contents buf
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Htmlit-based HTML generation for Arod *)
77+88+open Htmlit
99+1010+(** {1 Attribute Helpers} *)
1111+1212+let class_ c = At.class' c
1313+let id i = At.id i
1414+let href h = At.href h
1515+let alt a = At.alt a
1616+let src s = At.src s
1717+let title t = At.title t
1818+let name n = At.name n
1919+let content c = At.content c
2020+let loading l = At.v "loading" l
2121+let sizes s = At.v "sizes" s
2222+let srcset s = At.v "srcset" s
2323+let data_tag t = At.v "data-tag" t
2424+let frameborder f = At.v "frameborder" f
2525+let allowfullscreen = At.v "allowfullscreen" ""
2626+let sandbox s = At.v "sandbox" s
2727+let width w = At.v "width" w
2828+let height h = At.v "height" h
2929+let rel r = At.rel r
3030+let property p = At.v "property" p
3131+let http_equiv h = At.v "http-equiv" h
3232+let type_ t = At.type' t
3333+let lang l = At.lang l
3434+3535+(** {1 SVG Icons} *)
3636+3737+let svg_icon_paper =
3838+ El.unsafe_raw {|<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="18" height="18" fill="none" stroke="currentcolor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M16 7 C16 7 9 1 2 6 L2 28 C9 23 16 28 16 28 16 28 23 23 30 28 L30 6 C23 1 16 7 16 7 Z M16 7 L16 28" /></svg>|}
3939+4040+let svg_icon_project =
4141+ El.unsafe_raw {|<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="18" height="18" fill="none" stroke="currentcolor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M30 8 L2 8 2 26 30 26 Z M20 8 C20 8 20 4 16 4 12 4 12 8 12 8 M8 26 L8 8 M24 26 L24 8" /></svg>|}
4242+4343+let svg_icon_note =
4444+ El.unsafe_raw {|<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="18" height="18" fill="none" stroke="currentcolor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M27 15 L27 30 2 30 2 5 17 5 M30 6 L26 2 9 19 7 25 13 23 Z M22 6 L26 10 Z M9 19 L13 23 Z" /></svg>|}
4545+4646+let svg_icon_video =
4747+ El.unsafe_raw {|<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="18" height="18" fill="none" stroke="currentcolor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M22 13 L30 8 30 24 22 19 Z M2 8 L2 24 22 24 22 8 Z" /></svg>|}
4848+4949+let svg_icon_idea =
5050+ El.unsafe_raw {|<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="18" height="18" fill="none" stroke="currentcolor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M18 13 L26 2 8 13 14 19 6 30 24 19 Z" /></svg>|}
5151+5252+let svg_icon_search =
5353+ El.unsafe_raw {|<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>|}
5454+5555+(** {1 Date Formatting} *)
5656+5757+let int_to_date_suffix ~r n =
5858+ let suffix =
5959+ if n mod 10 = 1 && n mod 100 <> 11 then "st"
6060+ else if n mod 10 = 2 && n mod 100 <> 12 then "nd"
6161+ else if n mod 10 = 3 && n mod 100 <> 13 then "rd"
6262+ else "th"
6363+ in
6464+ let x = string_of_int n in
6565+ let x = if r && String.length x = 1 then " " ^ x else x in
6666+ x ^ suffix
6767+6868+let month_name = function
6969+ | 1 -> "Jan" | 2 -> "Feb" | 3 -> "Mar" | 4 -> "Apr"
7070+ | 5 -> "May" | 6 -> "Jun" | 7 -> "Jul" | 8 -> "Aug"
7171+ | 9 -> "Sep" | 10 -> "Oct" | 11 -> "Nov" | 12 -> "Dec"
7272+ | _ -> ""
7373+7474+let ptime_date ?(r=false) ?(with_d=false) (y, m, d) =
7575+ let ms = month_name m in
7676+ match with_d with
7777+ | false -> Printf.sprintf "%s %4d" ms y
7878+ | true -> Printf.sprintf "%s %s %4d" (int_to_date_suffix ~r d) ms y
7979+8080+(** {1 Image Rendering} *)
8181+8282+let img ?cl ?(alt_text="") ?(title_text="") img_ent =
8383+ let origin_url = Printf.sprintf "/images/%s.webp"
8484+ (Filename.chop_extension (Arod_model.Img.origin img_ent)) in
8585+8686+ let srcsets =
8787+ let variants = Arod_model.Img.variants img_ent in
8888+ String.concat ","
8989+ (Arod_model.Img.MS.fold (fun f (w, _h) acc ->
9090+ Printf.sprintf "/images/%s %dw" f w :: acc
9191+ ) variants [])
9292+ in
9393+9494+ let base_attrs = [
9595+ loading "lazy";
9696+ src origin_url;
9797+ srcset srcsets;
9898+ sizes "(max-width: 768px) 100vw, 33vw"
9999+ ] in
100100+101101+ let attrs = match cl with
102102+ | Some c -> class_ c :: base_attrs
103103+ | None -> base_attrs
104104+ in
105105+106106+ match alt_text with
107107+ | "%r" ->
108108+ El.figure ~at:[class_ "image-right"] [
109109+ El.img ~at:(At.alt title_text :: At.title title_text :: attrs) ();
110110+ El.figcaption [El.txt title_text]
111111+ ]
112112+ | "%c" ->
113113+ El.figure ~at:[class_ "image-center"] [
114114+ El.img ~at:(At.alt title_text :: At.title title_text :: attrs) ();
115115+ El.figcaption [El.txt title_text]
116116+ ]
117117+ | "%lc" ->
118118+ El.figure ~at:[class_ "image-left-float"] [
119119+ El.img ~at:(At.alt title_text :: At.title title_text :: attrs) ();
120120+ El.figcaption [El.txt title_text]
121121+ ]
122122+ | "%rc" ->
123123+ El.figure ~at:[class_ "image-right-float"] [
124124+ El.img ~at:(At.alt title_text :: At.title title_text :: attrs) ();
125125+ El.figcaption [El.txt title_text]
126126+ ]
127127+ | _ ->
128128+ El.img ~at:(At.alt alt_text :: At.title title_text :: attrs) ()
129129+130130+(** {1 Tag Rendering} *)
131131+132132+let render_tag ?active tag_value =
133133+ let active_cl = match active with Some true -> " tag-active" | _ -> "" in
134134+135135+ let icon, text =
136136+ match tag_value with
137137+ | `Slug t ->
138138+ (match Arod_model.lookup t with
139139+ | Some ent ->
140140+ let icon_name = match ent with
141141+ | `Paper _ -> Some "paper.svg"
142142+ | `Note _ -> Some "note.svg"
143143+ | `Project _ -> Some "project.svg"
144144+ | `Idea _ -> Some "idea.svg"
145145+ | `Video _ -> Some "video.svg"
146146+ in
147147+ icon_name, Arod_model.Entry.slug ent
148148+ | None -> None, t)
149149+ | `Set slug ->
150150+ let icon_name = match slug with
151151+ | "papers" -> Some "paper.svg"
152152+ | "notes" -> Some "note.svg"
153153+ | "projects" -> Some "project.svg"
154154+ | "ideas" -> Some "idea.svg"
155155+ | "videos" | "talks" -> Some "video.svg"
156156+ | _ -> None
157157+ in
158158+ icon_name, slug
159159+ | _ -> None, Arod_model.Tags.to_string tag_value
160160+ in
161161+162162+ let t = Arod_model.Tags.to_string tag_value in
163163+ let icon_el = match icon with
164164+ | None -> El.splice []
165165+ | Some icon_name ->
166166+ El.img ~at:[
167167+ alt "icon";
168168+ class_ "hide-mobile inline-icon";
169169+ src (Printf.sprintf "/assets/%s" icon_name)
170170+ ] ()
171171+ in
172172+173173+ El.span ~at:[
174174+ data_tag t;
175175+ class_ ("tag-label" ^ active_cl)
176176+ ] [icon_el; El.txt text]
177177+178178+let render_tags tags =
179179+ let filtered_tags = List.filter (function
180180+ | `Text _ | `Set _ -> true
181181+ | _ -> false
182182+ ) tags in
183183+ El.splice ~sep:(El.txt " ") (List.map render_tag filtered_tags)
184184+185185+(** {1 Entry Rendering} *)
186186+187187+let entry_href ?title_override ?(tag="h2") ent =
188188+ let title_text = match title_override with
189189+ | None -> Arod_model.Entry.title ent
190190+ | Some t -> t
191191+ in
192192+193193+ match ent with
194194+ | `Note { Arod_model.Note.index_page = true; _ } -> El.splice []
195195+ | _ ->
196196+ let h_fn = match tag with
197197+ | "h1" -> El.h1
198198+ | "h2" -> El.h2
199199+ | "h3" -> El.h3
200200+ | "h4" -> El.h4
201201+ | _ -> El.h2
202202+ in
203203+ h_fn [
204204+ El.a ~at:[href (Arod_model.Entry.site_url ent)] [El.txt title_text];
205205+ El.span ~at:[class_ "title-date"] [
206206+ El.txt " / ";
207207+ El.txt (ptime_date ~with_d:false (Arod_model.Entry.date ent))
208208+ ]
209209+ ]
210210+211211+let tags_meta ?link ?(tags=[]) ?date ent =
212212+ let tags = List.map Arod_model.Tags.of_string tags in
213213+ let link_el = match link with
214214+ | None -> El.a ~at:[href (Arod_model.Entry.site_url ent)] [El.txt "#"]
215215+ | Some l -> El.a ~at:[href l] [El.txt "#"]
216216+ in
217217+218218+ let date_str = ptime_date ~with_d:true
219219+ (match date with None -> Arod_model.Entry.date ent | Some d -> d) in
220220+221221+ El.div ~at:[class_ "note-meta"] [
222222+ link_el;
223223+ El.txt " ";
224224+ El.txt date_str;
225225+ El.txt " ";
226226+ El.span ~at:[class_ "tags"] [
227227+ render_tags (Arod_model.concat_tags tags (Arod_model.tags_of_ent ent))
228228+ ]
229229+ ]
230230+231231+let full_body ent =
232232+ El.unsafe_raw (Arod_model.md_to_html (Arod_model.Entry.body ent))
233233+234234+(** {1 Video Embedding} *)
235235+236236+let embed_video ~video_title ~url =
237237+ El.div ~at:[class_ "video-center"] [
238238+ El.iframe ~at:[
239239+ title video_title;
240240+ width "100%";
241241+ height "315px";
242242+ src url;
243243+ frameborder "0";
244244+ allowfullscreen;
245245+ sandbox "allow-same-origin allow-scripts allow-popups allow-forms"
246246+ ] []
247247+ ]
248248+249249+(** {1 Page Layout} *)
250250+251251+let page ?(image="/assets/imagetitle-default.jpg") ?(jsonld="") ~page_title ~description ~page_content () =
252252+ let cfg = Arod_model.get_config () in
253253+ let title_text = if page_title = "" then cfg.site.name else page_title in
254254+255255+ let head_els = [
256256+ El.meta ~at:[http_equiv "X-UA-Compatible"; content "ie=edge"] ();
257257+ El.meta ~at:[name "description"; content description] ();
258258+ El.meta ~at:[property "og:image"; content image] ();
259259+ El.meta ~at:[property "og:site_name"; content cfg.site.name] ();
260260+ El.meta ~at:[property "og:type"; content "object"] ();
261261+ El.meta ~at:[property "og:title"; content title_text] ();
262262+ El.meta ~at:[property "og:description"; content description] ();
263263+ El.meta ~at:[name "twitter:card"; content "summary_large_image"] ();
264264+ El.meta ~at:[name "twitter:title"; content title_text] ();
265265+ El.meta ~at:[name "twitter:description"; content description] ();
266266+ El.meta ~at:[name "twitter:image"; content image] ();
267267+ El.meta ~at:[name "theme-color"; content "#fff"] ();
268268+ El.meta ~at:[name "color-scheme"; content "white"] ();
269269+ El.link ~at:[rel "apple-touch-icon"; sizes "180x180"; href "/assets/apple-touch-icon.png"] ();
270270+ El.link ~at:[rel "icon"; type_ "image/png"; sizes "32x32"; href "/assets/favicon-32x32.png"] ();
271271+ El.link ~at:[rel "icon"; type_ "image/png"; sizes "16x16"; href "/assets/favicon-16x16.png"] ();
272272+ El.link ~at:[rel "alternate"; type_ "application/atom+xml"; At.title "Atom Feed"; href "/news.xml"] ();
273273+ El.link ~at:[rel "alternate"; type_ "application/feed+json"; At.title "JSON Feed"; href "/feed.json"] ();
274274+ El.link ~at:[rel "stylesheet"; href "/assets/site.css"] ();
275275+ El.link ~at:[rel "stylesheet"; href "/assets/highlight.min.css"] ();
276276+ El.unsafe_raw jsonld;
277277+ El.script ~at:[src "/assets/highlight.min.js"] [];
278278+ El.script [El.txt "hljs.highlightAll();"]
279279+ ] in
280280+281281+ let header_el = El.header ~at:[class_ "site-header"] [
282282+ El.div ~at:[class_ "header-content"] [
283283+ El.h1 ~at:[class_ "site-name"] [
284284+ El.a ~at:[href "/"] [El.txt cfg.site.name]
285285+ ];
286286+ El.nav ~at:[class_ "main-nav"] [
287287+ El.a ~at:[class_ "nav-link"; href "/papers"] [svg_icon_paper; El.txt "Papers"];
288288+ El.a ~at:[class_ "nav-link"; href "/projects"] [svg_icon_project; El.txt "Projects"];
289289+ El.a ~at:[class_ "nav-link"; href "/notes"] [svg_icon_note; El.txt "Notes"];
290290+ El.a ~at:[class_ "nav-link"; href "/videos"] [svg_icon_video; El.txt "Talks"];
291291+ El.a ~at:[class_ "nav-link"; href "/ideas"] [svg_icon_idea; El.txt "Ideas"];
292292+ ];
293293+ El.div ~at:[class_ "header-right"] [
294294+ El.div ~at:[class_ "search-container"] [
295295+ El.button ~at:[class_ "search-toggle"; At.v "aria-label" "Search"; id "search-toggle-btn"] [
296296+ svg_icon_search;
297297+ El.span ~at:[class_ "search-label"] [El.txt "Search"]
298298+ ]
299299+ ]
300300+ ]
301301+ ]
302302+ ] in
303303+304304+ let footer_el = El.footer [
305305+ El.txt (Printf.sprintf "Powered by Bushel | %s" cfg.site.name)
306306+ ] in
307307+308308+ let body_el = El.body ~at:[class_ "light"] [
309309+ header_el;
310310+ El.div ~at:[class_ "content-grid"] [page_content];
311311+ footer_el;
312312+ El.script ~at:[src "/assets/site.js"] [];
313313+ ] in
314314+315315+ El.page ~lang:"en" ~title:title_text ~more_head:(El.splice head_els) body_el
316316+317317+(** {1 Output Helpers} *)
318318+319319+let to_string el = El.to_string ~doctype:false el
320320+let to_page el = El.to_string ~doctype:true el
+260
arod/lib/arod_ideas.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Idea rendering for Arod webserver *)
77+88+open Htmlit
99+open Printf
1010+1111+module MI = Arod_model.Idea
1212+1313+let class_ c = At.class' c
1414+1515+let color_of_status =
1616+ let open MI in
1717+ function
1818+ | Available -> "#ddffdd"
1919+ | Discussion -> "#efee99"
2020+ | Ongoing -> "#ffeebb"
2121+ | Completed -> "#f0f0fe"
2222+ | Expired -> "#cccccc"
2323+2424+let status_to_long_string s =
2525+ let open MI in
2626+ function
2727+ | Available -> sprintf {|is <span class="idea-available">available</span> for being worked on|}
2828+ | Discussion -> sprintf {|is <span class="idea-discussion">under discussion</span> with a student but not yet confirmed|}
2929+ | Ongoing -> sprintf {|is currently <span class="idea-ongoing">being worked on</span> by %s|} s
3030+ | Completed -> sprintf {|has been <span class="idea-completed">completed</span> by %s|} s
3131+ | Expired -> sprintf {|has <span class="idea-expired">expired</span>|}
3232+3333+let level_to_long_string =
3434+ let open MI in
3535+ function
3636+ | Any -> " as a good starter project"
3737+ | PartII -> " as a Cambridge Computer Science Part II project"
3838+ | MPhil -> " as a Cambridge Computer Science Part III or MPhil project"
3939+ | PhD -> " as a Cambridge Computer Science PhD topic"
4040+ | Postdoc -> " as a postdoctoral project"
4141+4242+let idea_to_html_no_sidenotes idea =
4343+ let open MI in
4444+ let idea_url = "/ideas/" ^ idea.slug in
4545+4646+ let render_contacts contacts =
4747+ match contacts with
4848+ | [] -> El.splice []
4949+ | cs ->
5050+ let contact_links = List.filter_map (fun handle ->
5151+ match Arod_model.lookup_by_handle handle with
5252+ | Some contact ->
5353+ let name = Sortal_schema.Contact.name contact in
5454+ (match Sortal_schema.Contact.best_url contact with
5555+ | Some url -> Some (El.a ~at:[At.href url] [El.txt name])
5656+ | None -> Some (El.txt name))
5757+ | None ->
5858+ Some (El.txt ("@" ^ handle))
5959+ ) cs in
6060+ let rec intersperse_and = function
6161+ | [] -> []
6262+ | [x] -> [x]
6363+ | [x; y] -> [x; El.txt " and "; y]
6464+ | x :: xs -> x :: El.txt ", " :: intersperse_and xs
6565+ in
6666+ El.splice (intersperse_and contact_links)
6767+ in
6868+6969+ let sups = List.filter (fun x -> x <> "avsm") idea.supervisors in
7070+ let sups_el = match sups with
7171+ | [] -> El.splice []
7272+ | _ -> El.splice [El.txt " and cosupervised with "; render_contacts sups]
7373+ in
7474+7575+ let studs_el = match idea.students with
7676+ | [] -> El.splice []
7777+ | _ -> El.splice [render_contacts idea.students]
7878+ in
7979+8080+ let lev = match idea.level with
8181+ | Any -> ""
8282+ | PartII -> " (Part II)"
8383+ | MPhil -> " (MPhil)"
8484+ | PhD -> " (PhD)"
8585+ | Postdoc -> ""
8686+ in
8787+8888+ let status_and_info = match idea.status with
8989+ | Available -> El.splice [
9090+ El.a ~at:[At.href idea_url] [El.txt (MI.title idea)];
9191+ El.txt " ";
9292+ El.br ();
9393+ El.span ~at:[At.class' "idea-available"] [El.txt ("Available" ^ lev)];
9494+ El.txt " ";
9595+ sups_el
9696+ ]
9797+ | Discussion -> El.splice [
9898+ El.a ~at:[At.href idea_url] [El.txt (MI.title idea)];
9999+ El.txt " ";
100100+ El.br ();
101101+ El.span ~at:[At.class' "idea-discussion"] [El.txt ("Under discussion" ^ lev)];
102102+ El.txt " ";
103103+ sups_el
104104+ ]
105105+ | Ongoing -> El.splice [
106106+ El.a ~at:[At.href idea_url] [El.txt (MI.title idea)];
107107+ El.txt " ";
108108+ El.br ();
109109+ El.span ~at:[At.class' "idea-ongoing"] [El.txt ("Currently ongoing" ^ lev)];
110110+ El.txt " with ";
111111+ studs_el;
112112+ El.txt " ";
113113+ sups_el
114114+ ]
115115+ | Completed -> El.splice [
116116+ El.a ~at:[At.href idea_url] [El.txt (MI.title idea)];
117117+ El.txt " ";
118118+ El.br ();
119119+ El.span ~at:[At.class' "idea-completed"] [El.txt ("Completed" ^ lev)];
120120+ El.txt " by ";
121121+ studs_el;
122122+ El.txt " ";
123123+ sups_el;
124124+ El.txt (" in " ^ string_of_int idea.year)
125125+ ]
126126+ | Expired -> El.splice [
127127+ El.a ~at:[At.href idea_url] [El.txt (MI.title idea)];
128128+ El.txt " ";
129129+ El.br ();
130130+ El.span ~at:[At.class' "idea-expired"] [El.txt ("Expired" ^ lev)];
131131+ El.txt " ";
132132+ sups_el
133133+ ]
134134+ in
135135+ status_and_info
136136+137137+let sups_for i =
138138+ let v = match MI.status i with
139139+ | Completed -> "was"
140140+ | Ongoing -> "is"
141141+ | _ -> "may be" in
142142+ let sups = List.filter (fun x -> x <> "avsm") i.supervisors in
143143+ match sups with
144144+ | [] -> ""
145145+ | s -> " It " ^ v ^ " co-supervised with " ^ (Arod_view.map_and (sprintf "[@%s]") s) ^ "."
146146+147147+let one_idea_full i =
148148+ let studs = Arod_view.map_and (sprintf "[@%s]") (MI.students i) in
149149+ let r = Printf.sprintf "# %s\n\nThis is an idea proposed in %d%s, and %s.%s\n\n%s"
150150+ (MI.title i) (MI.year i) (level_to_long_string @@ MI.level i) (status_to_long_string studs (MI.status i)) (sups_for i) (MI.body i)
151151+ in
152152+ El.div ~at:[class_ "idea"] [
153153+ El.unsafe_raw (Arod_view.md_to_html r)
154154+ ]
155155+156156+let idea_for_feed i =
157157+ let studs = Arod_view.map_and (sprintf "[@%s]") (MI.students i) in
158158+ let r = Printf.sprintf "This is an idea proposed %s, and %s.%s"
159159+ (level_to_long_string @@ MI.level i) (status_to_long_string studs (MI.status i)) (sups_for i)
160160+ in
161161+ let (body_html, word_count_info) = Arod_view.truncated_body (`Idea i) in
162162+ (El.splice [
163163+ El.unsafe_raw (Arod_view.md_to_html r);
164164+ body_html
165165+ ], word_count_info)
166166+167167+let one_idea_brief i =
168168+ let studs = Arod_view.map_and (sprintf "[@%s]") (MI.students i) in
169169+ let r = Printf.sprintf "This is an idea proposed in %d%s, and %s.%s"
170170+ (MI.year i) (level_to_long_string @@ MI.level i) (status_to_long_string studs (MI.status i)) (sups_for i)
171171+ in
172172+ let (body_html, word_count_info) = Arod_view.truncated_body (`Idea i) in
173173+ (El.splice [
174174+ Arod_view.entry_href (`Idea i);
175175+ El.div ~at:[class_ "idea"] [
176176+ El.unsafe_raw (Arod_view.md_to_html r);
177177+ body_html
178178+ ]
179179+ ], word_count_info)
180180+181181+let view_ideas_by_project () =
182182+ let entries = Arod_model.get_entries () in
183183+ let all_ideas = Arod_model.Entry.ideas entries in
184184+ let all_projects = Arod_model.Entry.projects entries
185185+ |> List.sort Arod_model.Project.compare |> List.rev in
186186+187187+ let ideas_by_project = Hashtbl.create 32 in
188188+ List.iter (fun i ->
189189+ let proj_slug = MI.project i in
190190+ let existing = try Hashtbl.find ideas_by_project proj_slug with Not_found -> [] in
191191+ Hashtbl.replace ideas_by_project proj_slug (i :: existing)
192192+ ) all_ideas;
193193+194194+ Hashtbl.iter (fun proj_slug ideas ->
195195+ Hashtbl.replace ideas_by_project proj_slug (List.sort MI.compare ideas)
196196+ ) ideas_by_project;
197197+198198+ let project_sections = List.filter_map (fun p ->
199199+ let proj_slug = p.Arod_model.Project.slug in
200200+ match Hashtbl.find_opt ideas_by_project proj_slug with
201201+ | None -> None
202202+ | Some ideas ->
203203+ let idea_items = List.map (fun i ->
204204+ El.li ~at:[At.class' "idea-item"; At.v "data-status" (MI.status_to_string (MI.status i))] [
205205+ idea_to_html_no_sidenotes i
206206+ ]
207207+ ) ideas in
208208+ let thumbnail_md = Printf.sprintf "" proj_slug p.Arod_model.Project.title in
209209+ let thumbnail_html = El.unsafe_raw (Arod_view.md_to_html thumbnail_md) in
210210+ Some (El.div ~at:[At.class' "project-section"] [
211211+ El.h2 [
212212+ El.a ~at:[At.href ("/projects/" ^ proj_slug)] [El.txt p.Arod_model.Project.title]
213213+ ];
214214+ thumbnail_html;
215215+ El.p [Arod_view.truncated_body (`Project p) |> fst];
216216+ El.ul ~at:[At.class' "ideas-list"] idea_items
217217+ ])
218218+ ) all_projects in
219219+220220+ let status_filter = El.div ~at:[At.class' "status-filter"] [
221221+ El.h3 [El.txt "Filter by status:"];
222222+ El.label [
223223+ El.input ~at:[At.type' "checkbox"; At.id "filter-available"; At.checked; At.class' "status-checkbox"; At.v "data-status" "Available"] ();
224224+ El.span ~at:[At.class' "status-label idea-available"] [El.txt "Available"]
225225+ ];
226226+ El.label [
227227+ El.input ~at:[At.type' "checkbox"; At.id "filter-discussion"; At.checked; At.class' "status-checkbox"; At.v "data-status" "Discussion"] ();
228228+ El.span ~at:[At.class' "status-label idea-discussion"] [El.txt "Discussion"]
229229+ ];
230230+ El.label [
231231+ El.input ~at:[At.type' "checkbox"; At.id "filter-ongoing"; At.checked; At.class' "status-checkbox"; At.v "data-status" "Ongoing"] ();
232232+ El.span ~at:[At.class' "status-label idea-ongoing"] [El.txt "Ongoing"]
233233+ ];
234234+ El.label [
235235+ El.input ~at:[At.type' "checkbox"; At.id "filter-completed"; At.checked; At.class' "status-checkbox"; At.v "data-status" "Completed"] ();
236236+ El.span ~at:[At.class' "status-label idea-completed"] [El.txt "Completed"]
237237+ ];
238238+ El.label [
239239+ El.input ~at:[At.type' "checkbox"; At.id "filter-expired"; At.class' "status-checkbox"; At.v "data-status" "Expired"] ();
240240+ El.span ~at:[At.class' "status-label idea-expired"] [El.txt "Expired"]
241241+ ]
242242+ ] in
243243+244244+ let title = "Research Ideas" in
245245+ let description = "Research ideas grouped by project" in
246246+247247+ let intro = El.p [El.txt "These are research ideas for students at various levels (Part II, MPhil, PhD, and postdoctoral). Browse through the ideas below to find projects that interest you. You're also welcome to propose your own research ideas that align with our ongoing projects."] in
248248+249249+ let page_footer = Arod_footer.footer in
250250+ let page_content = El.splice [
251251+ El.article [
252252+ El.h1 [El.txt title];
253253+ intro;
254254+ El.splice project_sections
255255+ ];
256256+ El.aside [
257257+ status_filter
258258+ ]
259259+ ] in
260260+ Arod_page.page ~title ~page_content ~page_footer ~description ()
+202
arod/lib/arod_jsonfeed.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** JSON feed generation for Arod webserver *)
77+88+module E = Arod_model.Entry
99+module N = Arod_model.Note
1010+module C = Sortal_schema.Contact
1111+module P = Arod_model.Paper
1212+module J = Jsonfeed
1313+1414+let form_uri cfg path = cfg.Arod_config.site.base_url ^ path
1515+1616+let author cfg c =
1717+ let name = C.name c in
1818+ let url = match C.orcid c with
1919+ | Some orcid -> Some (Printf.sprintf "https://orcid.org/%s" orcid)
2020+ | None -> C.best_url c
2121+ in
2222+ let avatar = Some (form_uri cfg "/images/anil-headshot.webp") in
2323+ Jsonfeed.Author.create ?name:(Some name) ?url ?avatar ()
2424+2525+let item_of_note cfg note =
2626+ let e = `Note note in
2727+ let id = match N.doi note with
2828+ | Some doi ->
2929+ let is_valid_doi =
3030+ not (String.contains doi ' ') &&
3131+ not (String.contains doi '\t') &&
3232+ not (String.contains doi '\n') &&
3333+ String.length doi > 0
3434+ in
3535+ if is_valid_doi then
3636+ Printf.sprintf "https://doi.org/%s" doi
3737+ else
3838+ let note_title = N.title note in
3939+ failwith (Printf.sprintf "Invalid DOI in note '%s': '%s'" note_title doi)
4040+ | None -> form_uri cfg (E.site_url e)
4141+ in
4242+ let url = form_uri cfg (E.site_url e) in
4343+ let title = N.title note in
4444+ let date_published = N.origdate note in
4545+ let date_modified = N.datetime note in
4646+ let tags = N.tags note in
4747+4848+ let base_html = Arod_view.md_to_atom_html note.N.body in
4949+5050+ let is_perma = N.perma note in
5151+ let has_doi = match N.doi note with Some _ -> true | None -> false in
5252+ let html_with_refs =
5353+ if is_perma || has_doi then
5454+ let me = match Arod_model.lookup_by_handle cfg.Arod_config.site.author_handle with
5555+ | Some c -> c
5656+ | None -> failwith "Author not found"
5757+ in
5858+ let references = Bushel.Md.note_references (Arod_model.get_entries ()) me note in
5959+ if List.length references > 0 then
6060+ let refs_html =
6161+ let ref_items = List.map (fun (doi, citation, _) ->
6262+ let doi_url = Printf.sprintf "https://doi.org/%s" doi in
6363+ Printf.sprintf "<li>%s<a href=\"%s\" target=\"_blank\"><i>%s</i></a></li>"
6464+ citation doi_url doi
6565+ ) references |> String.concat "\n" in
6666+ Printf.sprintf "<h1>References</h1><ul>%s</ul>" ref_items
6767+ in
6868+ base_html ^ refs_html
6969+ else
7070+ base_html
7171+ else
7272+ base_html
7373+ in
7474+ let content = `Html html_with_refs in
7575+7676+ let external_url = match note.N.via with
7777+ | Some (_title, via_url) -> Some via_url
7878+ | None ->
7979+ match N.link note with
8080+ | `Local _ -> None
8181+ | `Ext (_l, u) -> Some u
8282+ in
8383+8484+ let image = match note.N.titleimage with
8585+ | Some img_slug ->
8686+ (try
8787+ let entries = Arod_model.get_entries () in
8888+ (match E.lookup_image entries img_slug with
8989+ | Some img_ent ->
9090+ let target_width = 1280 in
9191+ let open Arod_model.Img in
9292+ let variants = MS.bindings img_ent.variants in
9393+ let best_variant =
9494+ match variants with
9595+ | [] ->
9696+ Printf.sprintf "%s.webp" (Filename.chop_extension (origin img_ent))
9797+ | _ ->
9898+ let sorted = List.sort (fun (_f1,(w1,_h1)) (_f2,(w2,_h2)) ->
9999+ let diff1 = abs (w1 - target_width) in
100100+ let diff2 = abs (w2 - target_width) in
101101+ compare diff1 diff2
102102+ ) variants in
103103+ fst (List.hd sorted)
104104+ in
105105+ Some (Printf.sprintf "%s/images/%s" cfg.Arod_config.site.base_url best_variant)
106106+ | None -> None)
107107+ with Not_found -> None)
108108+ | None -> None
109109+ in
110110+111111+ let summary = note.N.synopsis in
112112+113113+ let attachments = match N.slug_ent note with
114114+ | Some slug ->
115115+ (match Arod_model.lookup slug with
116116+ | Some (`Paper p) ->
117117+ let slug = P.slug p in
118118+ let pdf_path = Filename.concat cfg.Arod_config.paths.static_dir
119119+ (Printf.sprintf "papers/%s.pdf" slug) in
120120+ if Sys.file_exists pdf_path then
121121+ let pdf_url = form_uri cfg (Printf.sprintf "/papers/%s.pdf" slug) in
122122+ let pdf_title = P.title p in
123123+ [J.Attachment.create ~url:pdf_url ~mime_type:"application/pdf" ~title:pdf_title ()]
124124+ else
125125+ (match P.best_url p with
126126+ | Some url when String.ends_with ~suffix:".pdf" url ->
127127+ let pdf_url = form_uri cfg url in
128128+ let pdf_title = P.title p in
129129+ [J.Attachment.create ~url:pdf_url ~mime_type:"application/pdf" ~title:pdf_title ()]
130130+ | _ -> [])
131131+ | _ -> [])
132132+ | None -> []
133133+ in
134134+135135+ let references =
136136+ let me = match Arod_model.lookup_by_handle cfg.Arod_config.site.author_handle with
137137+ | Some c -> c
138138+ | None -> failwith "Author not found"
139139+ in
140140+ Bushel.Md.note_references (Arod_model.get_entries ()) me note
141141+ |> List.map (fun (doi, _citation, ref_source) ->
142142+ let doi_url = Printf.sprintf "https://doi.org/%s" doi in
143143+ let cito = match ref_source with
144144+ | Bushel.Md.Paper -> [`CitesAsSourceDocument]
145145+ | Bushel.Md.Note -> [`CitesAsRelated]
146146+ | Bushel.Md.External -> [`Cites]
147147+ in
148148+ J.Reference.create ~url:doi_url ~doi ~cito ()
149149+ )
150150+ in
151151+152152+ let json_author = author cfg (Arod_model.lookup_by_handle cfg.site.author_handle |> Option.get) in
153153+154154+ Jsonfeed.Item.create
155155+ ~id
156156+ ~content
157157+ ~url
158158+ ?external_url
159159+ ?image
160160+ ?summary
161161+ ~title
162162+ ~date_published
163163+ ~date_modified
164164+ ~authors:[json_author]
165165+ ~tags
166166+ ~attachments
167167+ ~references
168168+ ()
169169+170170+let item_of_entry cfg (e:Arod_model.Entry.entry) =
171171+ match e with
172172+ | `Note n -> Some (item_of_note cfg n)
173173+ | _ -> None
174174+175175+let feed cfg uri entries =
176176+ let title = cfg.Arod_config.site.name ^ "'s feed" in
177177+ let home_page_url = cfg.site.base_url in
178178+ let feed_url = form_uri cfg uri in
179179+ let icon = cfg.site.base_url ^ "/assets/favicon.ico" in
180180+ let json_author = author cfg (Arod_model.lookup_by_handle cfg.site.author_handle |> Option.get) in
181181+ let authors = [json_author] in
182182+ let language = "en-US" in
183183+184184+ let items = List.filter_map (item_of_entry cfg) entries in
185185+186186+ Jsonfeed.create
187187+ ~title
188188+ ~home_page_url
189189+ ~feed_url
190190+ ~icon
191191+ ~authors
192192+ ~language
193193+ ~items
194194+ ()
195195+196196+let feed_string cfg uri entries =
197197+ let f = feed cfg uri entries in
198198+ match Jsonfeed.to_string f with
199199+ | Ok s -> s
200200+ | Error e ->
201201+ let msg = Fmt.str "Failed to encode JSON Feed: %a" Jsont.Error.pp e in
202202+ failwith msg
+222
arod/lib/arod_model.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Model layer bridging Bushel to the Arod webserver *)
77+88+(** Re-export Bushel modules for convenience *)
99+module Paper = Bushel.Paper
1010+module Note = Bushel.Note
1111+module Idea = Bushel.Idea
1212+module Project = Bushel.Project
1313+module Video = Bushel.Video
1414+module Entry = Bushel.Entry
1515+module Tags = Bushel.Tags
1616+module Md = Bushel.Md
1717+module Util = Bushel.Util
1818+module Img = Srcsetter
1919+module Contact = Sortal_schema.Contact
2020+2121+(** {1 Global State} *)
2222+2323+(** The loaded entries - set once at startup *)
2424+let entries : Bushel.Entry.t option ref = ref None
2525+2626+(** The site configuration *)
2727+let config : Arod_config.t option ref = ref None
2828+2929+(** Get the loaded entries, raising if not initialized *)
3030+let get_entries () =
3131+ match !entries with
3232+ | Some e -> e
3333+ | None -> failwith "Arod_model: entries not loaded"
3434+3535+(** Get the site config *)
3636+let get_config () =
3737+ match !config with
3838+ | Some c -> c
3939+ | None -> Arod_config.default
4040+4141+(** {1 Initialization} *)
4242+4343+(** Load entries from the configured data directory *)
4444+let init ~cfg fs =
4545+ config := Some cfg;
4646+ let image_output_dir = cfg.Arod_config.paths.images_dir in
4747+ let data_dir = cfg.paths.data_dir in
4848+ let loaded = Bushel_eio.Bushel_loader.load ~image_output_dir fs data_dir in
4949+ entries := Some loaded;
5050+ loaded
5151+5252+(** {1 Lookup Functions} *)
5353+5454+let lookup slug =
5555+ Entry.lookup (get_entries ()) slug
5656+5757+let lookup_exn slug =
5858+ Entry.lookup_exn (get_entries ()) slug
5959+6060+let lookup_image slug =
6161+ Entry.lookup_image (get_entries ()) slug
6262+6363+let lookup_by_handle handle =
6464+ let contacts = Entry.contacts (get_entries ()) in
6565+ List.find_opt (fun c -> Sortal_schema.Contact.handle c = handle) contacts
6666+6767+let lookup_by_name name =
6868+ Entry.lookup_by_name (get_entries ()) name
6969+7070+(** {1 Entry Accessors} *)
7171+7272+let papers () = Entry.papers (get_entries ())
7373+let notes () = Entry.notes (get_entries ())
7474+let ideas () = Entry.ideas (get_entries ())
7575+let projects () = Entry.projects (get_entries ())
7676+let videos () = Entry.videos (get_entries ())
7777+let contacts () = Entry.contacts (get_entries ())
7878+let images () = Entry.images (get_entries ())
7979+let all_entries () = Entry.all_entries (get_entries ())
8080+8181+(** {1 Author/Site Identity} *)
8282+8383+let author () =
8484+ let cfg = get_config () in
8585+ lookup_by_handle cfg.site.author_handle
8686+8787+let author_name () =
8888+ match author () with
8989+ | Some c -> Sortal_schema.Contact.name c
9090+ | None -> (get_config ()).site.author_name
9191+9292+let base_url () = (get_config ()).site.base_url
9393+let site_name () = (get_config ()).site.name
9494+let site_description () = (get_config ()).site.description
9595+9696+(** {1 Markdown Rendering} *)
9797+9898+(** Custom HTML renderer for Cmarkit that handles Bushel extensions *)
9999+let custom_html_renderer () =
100100+ let open Cmarkit in
101101+ let open Cmarkit_renderer.Context in
102102+ let inline c = function
103103+ | Inline.Image (img, _meta) ->
104104+ (* Handle bushel image syntax - :slug format *)
105105+ (match Inline.Link.reference img with
106106+ | `Inline (ld, _) ->
107107+ (match Link_definition.dest ld with
108108+ | Some (src, _) when Md.is_bushel_slug src ->
109109+ let slug = Md.strip_handle src in
110110+ let title = match Link_definition.title ld with
111111+ | Some lines -> String.concat "" (List.map Block_line.tight_to_string lines)
112112+ | None -> ""
113113+ in
114114+ let caption =
115115+ Inline.Link.text img
116116+ |> Inline.to_plain_text ~break_on_soft:false
117117+ |> fun r -> String.concat "\n" (List.map (String.concat "") r)
118118+ in
119119+ (* Check if this is a video *)
120120+ (match lookup slug with
121121+ | Some (`Video v) ->
122122+ let video_url = Video.url v in
123123+ let embed_url =
124124+ let uri = Uri.of_string video_url in
125125+ let path = Uri.path uri |> String.split_on_char '/' in
126126+ let path = List.map (function "watch" -> "embed" | p -> p) path in
127127+ Uri.with_path uri (String.concat "/" path) |> Uri.to_string
128128+ in
129129+ let html = Printf.sprintf
130130+ {|<div class="video-center"><iframe title="%s" width="100%%" height="315px" src="%s" frameborder="0" allowfullscreen sandbox="allow-same-origin allow-scripts allow-popups allow-forms"></iframe></div>|}
131131+ title embed_url
132132+ in
133133+ string c html;
134134+ true
135135+ | _ ->
136136+ (* Image handling *)
137137+ let img_info = lookup_image slug in
138138+ let dest = match img_info with
139139+ | Some img -> "/images/" ^ Img.name img
140140+ | None -> "/images/" ^ slug ^ ".webp"
141141+ in
142142+ let srcset_attr = match img_info with
143143+ | Some img ->
144144+ let variants = Img.variants img in
145145+ let parts = Img.MS.fold (fun name (w, _) acc ->
146146+ Printf.sprintf "/images/%s %dw" name w :: acc
147147+ ) variants [] in
148148+ if parts = [] then ""
149149+ else Printf.sprintf " srcset=\"%s\"" (String.concat ", " parts)
150150+ | None -> ""
151151+ in
152152+ (* Check for positioning directive *)
153153+ (match caption with
154154+ | "%c" | "%r" | "%lc" | "%rc" ->
155155+ let fig_class = match caption with
156156+ | "%c" -> "image-center"
157157+ | "%r" -> "image-right"
158158+ | "%lc" -> "image-left-float"
159159+ | "%rc" -> "image-right-float"
160160+ | _ -> "image-center"
161161+ in
162162+ let html = Printf.sprintf
163163+ {|<figure class="%s"><img class="content-image" src="%s" alt="%s" title="%s" loading="lazy"%s sizes="(max-width: 768px) 100vw, 33vw"><figcaption>%s</figcaption></figure>|}
164164+ fig_class dest title title srcset_attr title
165165+ in
166166+ string c html;
167167+ true
168168+ | _ ->
169169+ (* Regular image with content-image class for lightbox *)
170170+ let html = Printf.sprintf
171171+ {|<img class="content-image" src="%s" alt="%s" title="%s" loading="lazy"%s sizes="(max-width: 768px) 100vw, 33vw">|}
172172+ dest caption title srcset_attr
173173+ in
174174+ string c html;
175175+ true))
176176+ | _ -> false)
177177+ | _ -> false)
178178+ | _ -> false
179179+ in
180180+ let default = Cmarkit_html.renderer ~safe:false () in
181181+ Cmarkit_renderer.compose default (Cmarkit_renderer.make ~inline ())
182182+183183+(** Convert markdown to HTML with Bushel link resolution *)
184184+let md_to_html ?renderer md =
185185+ let open Cmarkit in
186186+ let doc = Doc.of_string ~strict:false ~resolver:Md.with_bushel_links md in
187187+ let mapper = Mapper.make ~inline:(Md.make_link_only_mapper (get_entries ())) () in
188188+ let mapped_doc = Mapper.map_doc mapper doc in
189189+ let r = match renderer with Some r -> r | None -> custom_html_renderer () in
190190+ Cmarkit_renderer.doc_to_string r mapped_doc
191191+192192+(** {1 Tag Helpers} *)
193193+194194+let tags_of_ent ent =
195195+ Entry.tags_of_ent (get_entries ()) ent
196196+197197+let concat_tags tags1 tags2 =
198198+ List.sort_uniq compare (tags1 @ tags2)
199199+200200+(** Count tags across all entries *)
201201+let count_tags_for_ents entries =
202202+ let counts = Hashtbl.create 32 in
203203+ List.iter (fun ent ->
204204+ let tags = Entry.tags_of_ent (get_entries ()) ent in
205205+ List.iter (fun tag ->
206206+ let current = Hashtbl.find_opt counts tag |> Option.value ~default:0 in
207207+ Hashtbl.replace counts tag (current + 1)
208208+ ) tags
209209+ ) entries;
210210+ counts
211211+212212+(** Get category tags with counts for the header navigation *)
213213+let cats () =
214214+ let entries = all_entries () in
215215+ let counts = count_tags_for_ents entries in
216216+ Hashtbl.fold (fun k v acc ->
217217+ match k with
218218+ | `Set "videos" -> acc (* Skip videos, use talks instead *)
219219+ | `Set _ -> (k, v) :: acc
220220+ | _ -> acc
221221+ ) counts []
222222+ |> List.sort (fun (a, _) (b, _) -> compare (Tags.to_string a) (Tags.to_string b))
+32
arod/lib/arod_notes.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** Note rendering for Arod webserver *)
77+88+open Htmlit
99+1010+let note_for_feed n =
1111+ let (body_html, word_count_info) = Arod_view.truncated_body (`Note n) in
1212+ (body_html, word_count_info)
1313+1414+let one_note_brief n =
1515+ let (body_html, word_count_info) = Arod_view.truncated_body (`Note n) in
1616+ (El.splice [
1717+ Arod_view.entry_href (`Note n);
1818+ body_html
1919+ ], word_count_info)
2020+2121+let one_note_full n =
2222+ let body = Arod_model.Note.body n in
2323+ let body_with_ref = match Arod_model.Note.slug_ent n with
2424+ | None -> body
2525+ | Some slug_ent ->
2626+ let parent_ent = Arod_model.lookup_exn slug_ent in
2727+ let parent_title = Arod_model.Entry.title parent_ent in
2828+ body ^ "\n\nRead more about [" ^ parent_title ^ "](:" ^ slug_ent ^ ")."
2929+ in
3030+ El.div ~at:[At.class' "note"] [
3131+ El.unsafe_raw (Arod_view.md_to_html body_with_ref)
3232+ ]
···158158 in
159159 (* Build table *)
160160 let rows = List.map (fun e ->
161161+ let thumb = match Bushel.Entry.thumbnail_slug entries e with
162162+ | Some s -> s
163163+ | None -> "-"
164164+ in
161165 [ type_string e
162166 ; Bushel.Entry.slug e
163167 ; truncate 50 (Bushel.Entry.title e)
164168 ; format_date (Bushel.Entry.date e)
169169+ ; thumb
165170 ]
166171 ) limited in
167172 let table = Table.make
168168- ~headers:["TYPE"; "SLUG"; "TITLE"; "DATE"]
173173+ ~headers:["TYPE"; "SLUG"; "TITLE"; "DATE"; "THUMBNAIL"]
169174 rows
170175 in
171176 Table.print table;
···235240 Printf.printf "Title: %s\n" (Bushel.Entry.title entry);
236241 Printf.printf "Date: %s\n" (format_date (Bushel.Entry.date entry));
237242 Printf.printf "URL: %s\n" (Bushel.Entry.site_url entry);
243243+ (match Bushel.Entry.thumbnail_slug entries entry with
244244+ | Some s -> Printf.printf "Thumbnail: %s\n" s
245245+ | None -> Printf.printf "Thumbnail: -\n");
238246 (match Bushel.Entry.synopsis entry with
239247 | Some s -> Printf.printf "Synopsis: %s\n" s
240248 | None -> ());
+3
ocaml-bushel/lib/bushel.ml
···8484module Types = Bushel_types
8585(** Common types and Jsont codecs. *)
86868787+module Doi_entry = Bushel_doi_entry
8888+(** DOI entries resolved from external sources. *)
8989+8790module Util = Bushel_util
8891(** Utility functions (word counting, text processing). *)
+98
ocaml-bushel/lib/bushel_doi_entry.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** DOI entries resolved from external sources via Zotero Translation Server *)
77+88+type status =
99+ | Resolved
1010+ | Failed of string
1111+1212+type t = {
1313+ doi : string;
1414+ title : string;
1515+ authors : string list;
1616+ year : int;
1717+ bibtype : string;
1818+ publisher : string;
1919+ resolved_at : string;
2020+ source_urls : string list;
2121+ status : status;
2222+ ignore : bool;
2323+}
2424+2525+type ts = t list
2626+2727+let get_string key fields =
2828+ match List.assoc_opt key fields with
2929+ | Some (`String s) -> s
3030+ | _ -> ""
3131+3232+let get_string_opt key fields =
3333+ match List.assoc_opt key fields with
3434+ | Some (`String s) -> Some s
3535+ | _ -> None
3636+3737+let get_int key fields =
3838+ match List.assoc_opt key fields with
3939+ | Some (`Float f) -> int_of_float f
4040+ | _ -> 0
4141+4242+let get_bool key fields =
4343+ match List.assoc_opt key fields with
4444+ | Some (`Bool b) -> b
4545+ | _ -> false
4646+4747+let get_strings key fields =
4848+ match List.assoc_opt key fields with
4949+ | Some (`A items) ->
5050+ List.filter_map (function `String s -> Some s | _ -> None) items
5151+ | _ -> []
5252+5353+let of_yaml_value = function
5454+ | `O fields ->
5555+ let doi = get_string "doi" fields in
5656+ let resolved_at = get_string "resolved_at" fields in
5757+ let source_urls =
5858+ match get_strings "source_urls" fields with
5959+ | [] ->
6060+ (match get_string_opt "source_url" fields with
6161+ | Some u -> [u]
6262+ | None -> [])
6363+ | urls -> urls
6464+ in
6565+ let ignore = get_bool "ignore" fields in
6666+ let error = get_string_opt "error" fields in
6767+ (match error with
6868+ | Some err ->
6969+ Some { doi; title = ""; authors = []; year = 0; bibtype = "";
7070+ publisher = ""; resolved_at; source_urls;
7171+ status = Failed err; ignore }
7272+ | None ->
7373+ let title = get_string "title" fields in
7474+ let authors = get_strings "authors" fields in
7575+ let year = get_int "year" fields in
7676+ let bibtype = get_string "bibtype" fields in
7777+ let publisher = get_string "publisher" fields in
7878+ Some { doi; title; authors; year; bibtype; publisher;
7979+ resolved_at; source_urls; status = Resolved; ignore })
8080+ | _ -> None
8181+8282+(** Load DOI entries from a YAML string *)
8383+let of_yaml_string str =
8484+ try
8585+ match Yamlrw.of_string str with
8686+ | `A entries -> List.filter_map of_yaml_value entries
8787+ | _ -> []
8888+ with Yamlrw.Yamlrw_error _ -> []
8989+9090+(** Find entry by DOI (excludes ignored entries) *)
9191+let find_by_doi entries doi =
9292+ List.find_opt (fun entry -> not entry.ignore && entry.doi = doi) entries
9393+9494+(** Find entry by source URL (excludes ignored entries) *)
9595+let find_by_url entries url =
9696+ List.find_opt (fun entry ->
9797+ not entry.ignore && List.mem url entry.source_urls
9898+ ) entries
+203-2
ocaml-bushel/lib/bushel_entry.ml
···2727 images : Srcsetter.t list;
2828 image_index : (string, Srcsetter.t) Hashtbl.t;
2929 data_dir : string;
3030+ doi_entries : Bushel_doi_entry.ts;
3031}
31323233(** {1 Constructors} *)
33343434-let v ~papers ~notes ~projects ~ideas ~videos ~contacts ?(images=[]) ~data_dir () =
3535+let v ~papers ~notes ~projects ~ideas ~videos ~contacts ?(images=[]) ?(doi_entries=[]) ~data_dir () =
3536 let slugs : slugs = Hashtbl.create 42 in
3637 let papers, old_papers = List.partition (fun p -> p.Bushel_paper.latest) papers in
3738 List.iter (fun n -> Hashtbl.add slugs n.Bushel_note.slug (`Note n)) notes;
···4243 (* Build image index *)
4344 let image_index = Hashtbl.create (List.length images) in
4445 List.iter (fun img -> Hashtbl.add image_index (Srcsetter.slug img) img) images;
4545- { slugs; papers; old_papers; notes; projects; ideas; videos; contacts; images; image_index; data_dir }
4646+ { slugs; papers; old_papers; notes; projects; ideas; videos; contacts; images; image_index; data_dir; doi_entries }
46474748(** {1 Accessors} *)
4849···5556let old_papers { old_papers; _ } = old_papers
5657let images { images; _ } = images
5758let data_dir { data_dir; _ } = data_dir
5959+let doi_entries { doi_entries; _ } = doi_entries
58605961(** {1 Image Lookup} *)
6062···194196 | `Slug t -> lk t
195197 | _ -> None
196198 ) tags
199199+200200+(** {1 Thumbnail Functions} *)
201201+202202+(** Get the smallest webp variant from a srcsetter image - prefers size just above 480px *)
203203+let smallest_webp_variant img =
204204+ let variants = Srcsetter.variants img in
205205+ let webp_variants =
206206+ Srcsetter.MS.bindings variants
207207+ |> List.filter (fun (name, _) -> String.ends_with ~suffix:".webp" name)
208208+ in
209209+ match webp_variants with
210210+ | [] ->
211211+ (* No webp variants - use the name field which is always webp *)
212212+ "/images/" ^ Srcsetter.name img
213213+ | variants ->
214214+ (* Prefer variants with width > 480px, choosing the smallest one above 480 *)
215215+ let large_variants = List.filter (fun (_, (w, _)) -> w > 480) variants in
216216+ let candidates = if large_variants = [] then variants else large_variants in
217217+ (* Find the smallest variant from candidates *)
218218+ let smallest = List.fold_left (fun acc (name, (w, h)) ->
219219+ match acc with
220220+ | None -> Some (name, w, h)
221221+ | Some (_, min_w, _) when w < min_w -> Some (name, w, h)
222222+ | _ -> acc
223223+ ) None candidates in
224224+ match smallest with
225225+ | Some (name, _, _) -> "/images/" ^ name
226226+ | None -> "/images/" ^ Srcsetter.name img
227227+228228+(** Get thumbnail slug for a contact *)
229229+let contact_thumbnail_slug contact =
230230+ (* Contact images use just the handle as slug *)
231231+ Some (Sortal_schema.Contact.handle contact)
232232+233233+(** Get thumbnail URL for a contact - resolved through srcsetter *)
234234+let contact_thumbnail entries contact =
235235+ match contact_thumbnail_slug contact with
236236+ | None -> None
237237+ | Some thumb_slug ->
238238+ match lookup_image entries thumb_slug with
239239+ | Some img -> Some (smallest_webp_variant img)
240240+ | None -> None
241241+242242+(** Extract the first image URL from markdown text *)
243243+let extract_first_image md =
244244+ let open Cmarkit in
245245+ let doc = Doc.of_string md in
246246+ let found_image = ref None in
247247+ let find_image_in_inline _mapper = function
248248+ | Inline.Image (img, _) ->
249249+ (match Inline.Link.reference img with
250250+ | `Inline (ld, _) ->
251251+ (match Link_definition.dest ld with
252252+ | Some (url, _) when !found_image = None ->
253253+ found_image := Some url;
254254+ Mapper.default
255255+ | _ -> Mapper.default)
256256+ | _ -> Mapper.default)
257257+ | _ -> Mapper.default
258258+ in
259259+ let mapper = Mapper.make ~inline:find_image_in_inline () in
260260+ let _ = Mapper.map_doc mapper doc in
261261+ !found_image
262262+263263+(** Extract the first video slug from markdown text by looking for bushel video links *)
264264+let extract_first_video entries md =
265265+ let open Cmarkit in
266266+ let doc = Doc.of_string md in
267267+ let found_video = ref None in
268268+ let find_video_in_inline _mapper = function
269269+ | Inline.Link (link, _) ->
270270+ (match Inline.Link.reference link with
271271+ | `Inline (ld, _) ->
272272+ (match Link_definition.dest ld with
273273+ | Some (url, _) when !found_video = None && String.starts_with ~prefix:":" url ->
274274+ let slug = String.sub url 1 (String.length url - 1) in
275275+ (match lookup entries slug with
276276+ | Some (`Video v) ->
277277+ found_video := Some (Bushel_video.uuid v);
278278+ Mapper.default
279279+ | _ -> Mapper.default)
280280+ | _ -> Mapper.default)
281281+ | _ -> Mapper.default)
282282+ | _ -> Mapper.default
283283+ in
284284+ let mapper = Mapper.make ~inline:find_video_in_inline () in
285285+ let _ = Mapper.map_doc mapper doc in
286286+ !found_video
287287+288288+(** Get thumbnail slug for an entry with fallbacks *)
289289+let rec thumbnail_slug entries = function
290290+ | `Paper p -> Some (Bushel_paper.slug p)
291291+ | `Video v -> Some (Bushel_video.uuid v)
292292+ | `Project p -> Some (Printf.sprintf "project-%s" (Bushel_project.slug p))
293293+ | `Idea i ->
294294+ let is_active = match Bushel_idea.status i with
295295+ | Bushel_idea.Available | Bushel_idea.Discussion | Bushel_idea.Ongoing -> true
296296+ | Bushel_idea.Completed | Bushel_idea.Expired -> false
297297+ in
298298+ if is_active then
299299+ (* Use first supervisor's face image *)
300300+ let supervisors = Bushel_idea.supervisors i in
301301+ match supervisors with
302302+ | sup :: _ ->
303303+ let handle = if String.length sup > 0 && sup.[0] = '@'
304304+ then String.sub sup 1 (String.length sup - 1)
305305+ else sup
306306+ in
307307+ (match List.find_opt (fun c -> Sortal_schema.Contact.handle c = handle) (contacts entries) with
308308+ | Some c ->
309309+ Some (Sortal_schema.Contact.handle c)
310310+ | None ->
311311+ (* Fallback to project thumbnail *)
312312+ let project_slug = Bushel_idea.project i in
313313+ (match lookup entries project_slug with
314314+ | Some p -> thumbnail_slug entries p
315315+ | None -> None))
316316+ | [] ->
317317+ (* No supervisors, use project thumbnail *)
318318+ let project_slug = Bushel_idea.project i in
319319+ (match lookup entries project_slug with
320320+ | Some p -> thumbnail_slug entries p
321321+ | None -> None)
322322+ else
323323+ (* Use project thumbnail for completed/expired ideas *)
324324+ let project_slug = Bushel_idea.project i in
325325+ (match lookup entries project_slug with
326326+ | Some p -> thumbnail_slug entries p
327327+ | None -> None)
328328+ | `Note n ->
329329+ (* Use titleimage if set, otherwise extract first image from body,
330330+ then try video, otherwise use slug_ent's thumbnail *)
331331+ (match Bushel_note.titleimage n with
332332+ | Some slug -> Some slug
333333+ | None ->
334334+ match extract_first_image (Bushel_note.body n) with
335335+ | Some url when String.starts_with ~prefix:":" url ->
336336+ Some (String.sub url 1 (String.length url - 1))
337337+ | Some _ -> None
338338+ | None ->
339339+ match extract_first_video entries (Bushel_note.body n) with
340340+ | Some video_uuid -> Some video_uuid
341341+ | None ->
342342+ (* Fallback to slug_ent's thumbnail if present *)
343343+ match Bushel_note.slug_ent n with
344344+ | Some slug_ent ->
345345+ (match lookup entries slug_ent with
346346+ | Some entry -> thumbnail_slug entries entry
347347+ | None -> None)
348348+ | None -> None)
349349+350350+(** Get thumbnail URL for an entry with fallbacks - resolved through srcsetter *)
351351+let thumbnail entries entry =
352352+ match thumbnail_slug entries entry with
353353+ | None -> None
354354+ | Some thumb_slug ->
355355+ match lookup_image entries thumb_slug with
356356+ | Some img -> Some (smallest_webp_variant img)
357357+ | None ->
358358+ (* For projects, fallback to supervisor faces if project image doesn't exist *)
359359+ (match entry with
360360+ | `Project p ->
361361+ (* Find ideas for this project *)
362362+ let project_ideas = List.filter (fun idea ->
363363+ Bushel_idea.project idea = ":" ^ Bushel_project.slug p
364364+ ) (ideas entries) in
365365+ (* Collect all unique supervisors from these ideas *)
366366+ let all_supervisors =
367367+ List.fold_left (fun acc idea ->
368368+ List.fold_left (fun acc2 sup ->
369369+ if List.mem sup acc2 then acc2 else sup :: acc2
370370+ ) acc (Bushel_idea.supervisors idea)
371371+ ) [] project_ideas
372372+ in
373373+ (* Split into avsm and others, preferring others first *)
374374+ let (others, avsm) = List.partition (fun sup ->
375375+ let handle = if String.length sup > 0 && sup.[0] = '@'
376376+ then String.sub sup 1 (String.length sup - 1)
377377+ else sup
378378+ in
379379+ handle <> "avsm"
380380+ ) all_supervisors in
381381+ let ordered_supervisors = others @ avsm in
382382+ let rec try_supervisors = function
383383+ | [] -> None
384384+ | sup :: rest ->
385385+ let handle = if String.length sup > 0 && sup.[0] = '@'
386386+ then String.sub sup 1 (String.length sup - 1)
387387+ else sup
388388+ in
389389+ (match List.find_opt (fun c -> Sortal_schema.Contact.handle c = handle) (contacts entries) with
390390+ | Some c ->
391391+ (match lookup_image entries (Sortal_schema.Contact.handle c) with
392392+ | Some img -> Some (smallest_webp_variant img)
393393+ | None -> try_supervisors rest)
394394+ | None -> try_supervisors rest)
395395+ in
396396+ try_supervisors ordered_supervisors
397397+ | _ -> None)
+19
ocaml-bushel/lib/bushel_entry.mli
···3030 videos:Bushel_video.t list ->
3131 contacts:Sortal_schema.Contact.t list ->
3232 ?images:Srcsetter.t list ->
3333+ ?doi_entries:Bushel_doi_entry.ts ->
3334 data_dir:string ->
3435 unit ->
3536 t
···4647val old_papers : t -> Bushel_paper.ts
4748val images : t -> Srcsetter.t list
4849val data_dir : t -> string
5050+val doi_entries : t -> Bushel_doi_entry.ts
49515052(** {1 Lookup Functions} *)
5153···127129128130val mention_entries : t -> Bushel_tags.t list -> entry list
129131(** [mention_entries entries tags] returns entries mentioned in the tags. *)
132132+133133+(** {1 Thumbnail Functions} *)
134134+135135+val smallest_webp_variant : Srcsetter.t -> string
136136+(** [smallest_webp_variant img] returns URL path to smallest webp variant above 480px. *)
137137+138138+val contact_thumbnail_slug : Sortal_schema.Contact.t -> string option
139139+(** [contact_thumbnail_slug contact] returns the image slug for a contact. *)
140140+141141+val contact_thumbnail : t -> Sortal_schema.Contact.t -> string option
142142+(** [contact_thumbnail entries contact] returns the thumbnail URL for a contact. *)
143143+144144+val thumbnail_slug : t -> entry -> string option
145145+(** [thumbnail_slug entries entry] returns the image slug for an entry. *)
146146+147147+val thumbnail : t -> entry -> string option
148148+(** [thumbnail entries entry] returns the thumbnail URL for an entry. *)
+312
ocaml-bushel/lib/bushel_md.ml
···1717 - Plain HTML mode for feeds and simple output
1818*)
19192020+(** {1 Sidenote Types}
2121+2222+ Sidenote data types for interactive previews on hover.
2323+ These are defined here as Cmarkit inline extensions that can be
2424+ generated by the sidenote mapper and rendered by the webserver. *)
2525+2626+type sidenote_data =
2727+ | Contact_note of Sortal_schema.Contact.t * string
2828+ | Paper_note of Bushel_paper.t * string
2929+ | Idea_note of Bushel_idea.t * string
3030+ | Note_note of Bushel_note.t * string
3131+ | Project_note of Bushel_project.t * string
3232+ | Video_note of Bushel_video.t * string
3333+ | Footnote_note of string * Cmarkit.Block.t * string
3434+3535+(** Extensible inline for sidenotes *)
3636+type Cmarkit.Inline.t += Side_note of sidenote_data
3737+2038(** {1 Link Detection} *)
21392240let is_bushel_slug = String.starts_with ~prefix:":"
···103121 | _ -> None)
104122 | _ -> None
105123124124+(** {1 Sidenote Mapper}
125125+126126+ Creates sidenotes for Bushel links. Used for interactive previews
127127+ on the main website. *)
128128+129129+let make_sidenote_mapper entries =
130130+ let open Cmarkit in
131131+ fun _m ->
132132+ function
133133+ | Inline.Link (lb, meta) ->
134134+ (match link_target_is_bushel lb with
135135+ | Some (url, title) ->
136136+ let s = strip_handle url in
137137+ if is_tag_slug url then
138138+ (* Tag link - keep as regular link with ## prefix for renderer *)
139139+ let txt = Inline.Text (title, meta) in
140140+ let ld = Link_definition.make ~dest:(url, meta) () in
141141+ let ll = `Inline (ld, meta) in
142142+ let link = Inline.Link.make txt ll in
143143+ Mapper.ret (Inline.Link (link, meta))
144144+ else if is_contact_slug url then
145145+ (* Contact sidenote *)
146146+ (match List.find_opt (fun c -> Sortal_schema.Contact.handle c = s) (Bushel_entry.contacts entries) with
147147+ | Some c ->
148148+ let sidenote = Side_note (Contact_note (c, title)) in
149149+ Mapper.ret sidenote
150150+ | None ->
151151+ (* Contact not found, fallback to text *)
152152+ let txt = Inline.Text (title, meta) in
153153+ Mapper.ret txt)
154154+ else
155155+ (* Check entry type and generate appropriate sidenote *)
156156+ (match Bushel_entry.lookup entries s with
157157+ | Some (`Paper p) ->
158158+ let sidenote = Side_note (Paper_note (p, title)) in
159159+ Mapper.ret sidenote
160160+ | Some (`Idea i) ->
161161+ let sidenote = Side_note (Idea_note (i, title)) in
162162+ Mapper.ret sidenote
163163+ | Some (`Note n) ->
164164+ let sidenote = Side_note (Note_note (n, title)) in
165165+ Mapper.ret sidenote
166166+ | Some (`Project p) ->
167167+ let sidenote = Side_note (Project_note (p, title)) in
168168+ Mapper.ret sidenote
169169+ | Some (`Video v) ->
170170+ let sidenote = Side_note (Video_note (v, title)) in
171171+ Mapper.ret sidenote
172172+ | None ->
173173+ (* Entry not found, use regular link *)
174174+ let dest = Bushel_entry.lookup_site_url entries s in
175175+ let txt = Inline.Text (title, meta) in
176176+ let ld = Link_definition.make ~dest:(dest, meta) () in
177177+ let ll = `Inline (ld, meta) in
178178+ let link = Inline.Link.make txt ll in
179179+ Mapper.ret (Inline.Link (link, meta)))
180180+ | None ->
181181+ (* Handle reference-style links *)
182182+ (match Inline.Link.referenced_label lb with
183183+ | Some l ->
184184+ let m = Label.meta l in
185185+ (match Meta.find authorlink m with
186186+ | Some () ->
187187+ let slug = Label.key l in
188188+ let s = strip_handle slug in
189189+ (match List.find_opt (fun c -> Sortal_schema.Contact.handle c = s) (Bushel_entry.contacts entries) with
190190+ | Some c ->
191191+ let name = Sortal_schema.Contact.name c in
192192+ let sidenote = Side_note (Contact_note (c, name)) in
193193+ Mapper.ret sidenote
194194+ | None ->
195195+ let title = Inline.Link.text lb |> text_of_inline in
196196+ let txt = Inline.Text (title, meta) in
197197+ Mapper.ret txt)
198198+ | None ->
199199+ (match Meta.find sluglink m with
200200+ | Some () ->
201201+ let slug = Label.key l in
202202+ if is_bushel_slug slug then
203203+ let s = strip_handle slug in
204204+ let title = Inline.Link.text lb |> text_of_inline in
205205+ (match Bushel_entry.lookup entries s with
206206+ | Some (`Paper p) -> Mapper.ret (Side_note (Paper_note (p, title)))
207207+ | Some (`Idea i) -> Mapper.ret (Side_note (Idea_note (i, title)))
208208+ | Some (`Note n) -> Mapper.ret (Side_note (Note_note (n, title)))
209209+ | Some (`Project p) -> Mapper.ret (Side_note (Project_note (p, title)))
210210+ | Some (`Video v) -> Mapper.ret (Side_note (Video_note (v, title)))
211211+ | None ->
212212+ let dest = Bushel_entry.lookup_site_url entries s in
213213+ let txt = Inline.Text (title, meta) in
214214+ let ld = Link_definition.make ~dest:(dest, meta) () in
215215+ let ll = `Inline (ld, meta) in
216216+ let link = Inline.Link.make txt ll in
217217+ Mapper.ret (Inline.Link (link, meta)))
218218+ else if is_tag_slug slug then
219219+ let sh = strip_handle slug in
220220+ let txt = Inline.Text (sh, meta) in
221221+ let ld = Link_definition.make ~dest:("#", meta) () in
222222+ let ll = `Inline (ld, meta) in
223223+ let link = Inline.Link.make txt ll in
224224+ Mapper.ret (Inline.Link (link, meta))
225225+ else Mapper.default
226226+ | None -> Mapper.default))
227227+ | None -> Mapper.default))
228228+ | Inline.Image (lb, meta) ->
229229+ (* Handle images with bushel slugs *)
230230+ (match image_target_is_bushel lb with
231231+ | Some (url, alt, caption) ->
232232+ let s = strip_handle url in
233233+ (* Check if this is a video - if so, use /videos/ path *)
234234+ (match Bushel_entry.lookup entries s with
235235+ | Some (`Video _) ->
236236+ let dest = Printf.sprintf "/videos/%s" s in
237237+ let txt = Inline.Text (caption, meta) in
238238+ let ld = Link_definition.make ?title:alt ~dest:(dest, meta) () in
239239+ let ll = `Inline (ld, meta) in
240240+ let img = Inline.Link.make txt ll in
241241+ Mapper.ret (Inline.Image (img, meta))
242242+ | _ ->
243243+ (* Convert bushel slug to /images/ path *)
244244+ let dest = Printf.sprintf "/images/%s.webp" s in
245245+ let txt = Inline.Text (caption, meta) in
246246+ let ld = Link_definition.make ?title:alt ~dest:(dest, meta) () in
247247+ let ll = `Inline (ld, meta) in
248248+ let img = Inline.Link.make txt ll in
249249+ Mapper.ret (Inline.Image (img, meta)))
250250+ | None -> Mapper.default)
251251+ | _ -> Mapper.default
252252+253253+(** Alias for compatibility *)
254254+let make_bushel_inline_mapper = make_sidenote_mapper
255255+106256(** {1 Link-Only Mapper}
107257108258 Converts Bushel links to regular HTML links without sidenotes.
···179329 | None -> Mapper.default))
180330 | None -> Mapper.default))
181331 | _ -> Mapper.default
332332+333333+(** Alias for compatibility *)
334334+let make_bushel_link_only_mapper _defs = make_link_only_mapper
182335183336(** {1 Slug Scanning} *)
184337···623776 let mapper = Mapper.make ~inline:(make_to_markdown_mapper ~base_url ~image_base entries) () in
624777 let mapped_doc = Mapper.map_doc mapper doc in
625778 Cmarkit_commonmark.of_doc mapped_doc
779779+780780+(** {1 References}
781781+782782+ Reference extraction for CiTO annotations. *)
783783+784784+(** Reference source type for CiTO annotations *)
785785+type reference_source =
786786+ | Paper (** CitesAsSourceDocument *)
787787+ | Note (** CitesAsRelated *)
788788+ | External (** Cites *)
789789+790790+(** Extract references (papers/notes with DOIs) from a note.
791791+ Returns a list of (doi, citation_text, reference_source) tuples.
792792+793793+ @param entries The entry collection
794794+ @param default_author The default author contact for notes without explicit author
795795+ @param note The note to extract references from *)
796796+let note_references entries (default_author:Sortal_schema.Contact.t) note =
797797+ let refs = ref [] in
798798+799799+ (* Helper to format author name: extract last name from full name *)
800800+ let format_author_last name =
801801+ let parts = String.split_on_char ' ' name in
802802+ List.nth parts (List.length parts - 1)
803803+ in
804804+805805+ (* Helper to format a citation *)
806806+ let format_citation ~authors ~year ~title ~publisher =
807807+ let author_str = match authors with
808808+ | [] -> ""
809809+ | [author] -> format_author_last author ^ " "
810810+ | author :: _ -> (format_author_last author) ^ " et al "
811811+ in
812812+ let pub_str = match publisher with
813813+ | None | Some "" -> ""
814814+ | Some p -> p ^ ". "
815815+ in
816816+ Printf.sprintf "%s(%d). %s. %s" author_str year title pub_str
817817+ in
818818+819819+ (* Check slug_ent if it exists *)
820820+ (match Bushel_note.slug_ent note with
821821+ | Some slug ->
822822+ (match Bushel_entry.lookup entries slug with
823823+ | Some (`Paper p) ->
824824+ (match Bushel_paper.doi p with
825825+ | Some doi ->
826826+ let authors = Bushel_paper.authors p in
827827+ let year = Bushel_paper.year p in
828828+ let title = Bushel_paper.title p in
829829+ let publisher = Some (Bushel_paper.publisher p) in
830830+ let citation = format_citation ~authors ~year ~title ~publisher in
831831+ refs := (doi, citation, Paper) :: !refs
832832+ | None -> ())
833833+ | Some (`Note n) ->
834834+ (match Bushel_note.doi n with
835835+ | Some doi ->
836836+ let authors = match Bushel_note.author n with
837837+ | Some a -> [a]
838838+ | None -> [Sortal_schema.Contact.name default_author]
839839+ in
840840+ let (year, _, _) = Bushel_note.date n in
841841+ let title = Bushel_note.title n in
842842+ let publisher = None in
843843+ let citation = format_citation ~authors ~year ~title ~publisher in
844844+ refs := (doi, citation, Note) :: !refs
845845+ | None -> ())
846846+ | _ -> ())
847847+ | None -> ());
848848+849849+ (* Scan body for bushel references *)
850850+ let slugs = scan_for_slugs entries (Bushel_note.body note) in
851851+ List.iter (fun slug ->
852852+ (* Strip leading : or @ from slug before lookup *)
853853+ let normalized_slug = strip_handle slug in
854854+ match Bushel_entry.lookup entries normalized_slug with
855855+ | Some (`Paper p) ->
856856+ (match Bushel_paper.doi p with
857857+ | Some doi ->
858858+ let authors = Bushel_paper.authors p in
859859+ let year = Bushel_paper.year p in
860860+ let title = Bushel_paper.title p in
861861+ let publisher = Some (Bushel_paper.publisher p) in
862862+ let citation = format_citation ~authors ~year ~title ~publisher in
863863+ (* Check if doi already exists in refs *)
864864+ if not (List.exists (fun (d, _, _) -> d = doi) !refs) then
865865+ refs := (doi, citation, Paper) :: !refs
866866+ | None -> ())
867867+ | Some (`Note n) ->
868868+ (match Bushel_note.doi n with
869869+ | Some doi ->
870870+ let authors = match Bushel_note.author n with
871871+ | Some a -> [a]
872872+ | None -> [Sortal_schema.Contact.name default_author]
873873+ in
874874+ let (year, _, _) = Bushel_note.date n in
875875+ let title = Bushel_note.title n in
876876+ let publisher = None in
877877+ let citation = format_citation ~authors ~year ~title ~publisher in
878878+ (* Check if doi already exists in refs *)
879879+ if not (List.exists (fun (d, _, _) -> d = doi) !refs) then
880880+ refs := (doi, citation, Note) :: !refs
881881+ | None -> ())
882882+ | _ -> ()
883883+ ) slugs;
884884+885885+ (* Scan body for external DOI URLs and resolve from cache *)
886886+ let body = Bushel_note.body note in
887887+ let doi_url_pattern = Re.Perl.compile_pat "https?://(?:dx\\.)?doi\\.org/([^)\\s\"'>]+)" in
888888+ let doi_matches = Re.all doi_url_pattern body in
889889+ let doi_entries = Bushel_entry.doi_entries entries in
890890+ List.iter (fun group ->
891891+ try
892892+ let encoded_doi = Re.Group.get group 1 in
893893+ let doi = Uri.pct_decode encoded_doi in
894894+ if not (List.exists (fun (d, _, _) -> d = doi) !refs) then
895895+ match Bushel_doi_entry.find_by_doi doi_entries doi with
896896+ | Some doi_entry when doi_entry.status = Resolved ->
897897+ let citation = format_citation
898898+ ~authors:doi_entry.authors
899899+ ~year:doi_entry.year
900900+ ~title:doi_entry.title
901901+ ~publisher:(Some doi_entry.publisher)
902902+ in
903903+ refs := (doi, citation, External) :: !refs
904904+ | _ ->
905905+ refs := (doi, doi, External) :: !refs
906906+ with _ -> ()
907907+ ) doi_matches;
908908+909909+ (* Scan body for publisher URLs and resolve from DOI cache *)
910910+ let publisher_pattern = Re.Perl.compile_pat "https?://(?:(?:www\\.)?(?:linkinghub\\.elsevier\\.com|(?:www\\.)?sciencedirect\\.com/science/article|ieeexplore\\.ieee\\.org|academic\\.oup\\.com|nature\\.com|journals\\.sagepub\\.com|garfield\\.library\\.upenn\\.edu|link\\.springer\\.com|arxiv\\.org/abs)/[^)\\s\"'>]+|(?:dl\\.acm\\.org|(?:www\\.)?tandfonline\\.com)/doi(?:/pdf)?/10\\.[^)\\s\"'>]+)" in
911911+ let publisher_matches = Re.all publisher_pattern body in
912912+ List.iter (fun group ->
913913+ try
914914+ let url = Re.Group.get group 0 in
915915+ match Bushel_doi_entry.find_by_url doi_entries url with
916916+ | Some doi_entry when doi_entry.status = Resolved ->
917917+ let doi = doi_entry.doi in
918918+ if not (List.exists (fun (d, _, _) -> d = doi) !refs) then
919919+ let citation = format_citation
920920+ ~authors:doi_entry.authors
921921+ ~year:doi_entry.year
922922+ ~title:doi_entry.title
923923+ ~publisher:(Some doi_entry.publisher)
924924+ in
925925+ refs := (doi, citation, External) :: !refs
926926+ | _ -> ()
927927+ with _ -> ()
928928+ ) publisher_matches;
929929+930930+ (* Filter out the note's own DOI from references *)
931931+ let own_doi = Bushel_note.doi note in
932932+ let filtered_refs = List.filter (fun (doi, _, _) ->
933933+ match own_doi with
934934+ | Some own -> doi <> own
935935+ | None -> true
936936+ ) !refs in
937937+ List.rev filtered_refs
+13-1
ocaml-bushel/lib_eio/bushel_loader.ml
···139139 | None -> []
140140 in
141141 Log.info (fun m -> m "Loaded %d images" (List.length images));
142142+ let doi_entries =
143143+ let doi_path = Filename.concat base "data/doi.yml" in
144144+ try
145145+ let content = Eio.Path.load Eio.Path.(fs / doi_path) in
146146+ let entries = Bushel.Doi_entry.of_yaml_string content in
147147+ Log.info (fun m -> m "Loaded %d DOI entries from %s" (List.length entries) doi_path);
148148+ entries
149149+ with
150150+ | Eio.Io (Eio.Fs.E (Eio.Fs.Not_found _), _) ->
151151+ Log.info (fun m -> m "No DOI cache found at %s" doi_path);
152152+ []
153153+ in
142154 let data_dir = Filename.concat base "data" in
143143- let entries = Bushel.Entry.v ~papers ~notes ~projects ~ideas ~videos ~contacts ~images ~data_dir () in
155155+ let entries = Bushel.Entry.v ~papers ~notes ~projects ~ideas ~videos ~contacts ~images ~doi_entries ~data_dir () in
144156 Log.info (fun m -> m "Building link graph");
145157 let graph = build_link_graph entries in
146158 Bushel.Link_graph.set_graph graph;
+12-2
ocaml-frontmatter/lib/frontmatter.ml
···3636 with _ -> None
3737 else None
38383939+(** Normalize a slug to match Jekyll's slug_of_string behavior:
4040+ map all non-alphanumeric characters to hyphens and lowercase. *)
4141+let normalize_slug s =
4242+ let mapped = String.map (fun c ->
4343+ match c with
4444+ | 'a'..'z' | 'A'..'Z' | '0'..'9' -> c
4545+ | _ -> '-'
4646+ ) s in
4747+ String.lowercase_ascii mapped
4848+3949let slug_of_fname fname =
4050 let basename = Filename.basename fname in
4151 let no_ext = Filename.chop_extension basename in
4252 match parse_date_prefix no_ext with
4343- | Some (date, slug) -> Ok (slug, Some date)
4444- | None -> Ok (no_ext, None)
5353+ | Some (date, slug) -> Ok (normalize_slug slug, Some date)
5454+ | None -> Ok (normalize_slug no_ext, None)
45554656(** Parse frontmatter using yamlrw's streaming parser.
4757 Uses multi-document support to find the document boundary,
+12-73
ocaml-tomlt/lib/tomlt.ml
···5656 else kind ^ " " ^ to_string sort
5757end
58585959-(* ---- Helpers ---- *)
6060-6161-(* Result syntax for cleaner monadic chaining *)
6262-module Result_syntax = struct
6363- let ( let* ) = Result.bind
6464- let ( let+ ) r f = Result.map f r
6565-end
6666-6767-(* Chain comparisons: return first non-zero, or final comparison *)
6868-let ( <?> ) c lazy_c = if c <> 0 then c else Lazy.force lazy_c
5959+let ( <?> ) c c' = if c <> 0 then c else c'
69607061(* Find first char matching predicate *)
7162let string_index_opt p s =
···10293 | UTC, Offset _ -> -1
10394 | Offset _, UTC -> 1
10495 | Offset { hours = h1; minutes = m1 }, Offset { hours = h2; minutes = m2 } ->
105105- Int.compare h1 h2 <?> lazy (Int.compare m1 m2)
9696+ Int.compare h1 h2 <?> Int.compare m1 m2
1069710798 let to_string = function
10899 | UTC -> "Z"
···136127137128 let compare a b =
138129 Int.compare a.year b.year
139139- <?> lazy (Int.compare a.month b.month)
140140- <?> lazy (Int.compare a.day b.day)
130130+ <?> Int.compare a.month b.month
131131+ <?> Int.compare a.day b.day
141132142133 let to_string d = Printf.sprintf "%04d-%02d-%02d" d.year d.month d.day
143134···171162172163 let compare a b =
173164 Int.compare a.hour b.hour
174174- <?> lazy (Int.compare a.minute b.minute)
175175- <?> lazy (Int.compare a.second b.second)
176176- <?> lazy (Float.compare a.frac b.frac)
165165+ <?> Int.compare a.minute b.minute
166166+ <?> Int.compare a.second b.second
167167+ <?> Float.compare a.frac b.frac
177168178169 (* Remove trailing zeros from a string, keeping at least one char *)
179170 let rstrip_zeros s =
···221212222213 let compare a b =
223214 Date.compare a.date b.date
224224- <?> lazy (Time.compare a.time b.time)
225225- <?> lazy (Tz.compare a.tz b.tz)
215215+ <?> Time.compare a.time b.time
216216+ <?> Tz.compare a.tz b.tz
226217227218 let to_string dt =
228219 Printf.sprintf "%sT%s%s"
···233224 let pp fmt dt = Format.pp_print_string fmt (to_string dt)
234225235226 let of_string s =
236236- let open Result_syntax in
227227+ let open Result.Syntax in
237228 match find_datetime_sep s with
238229 | None -> Error "missing date/time separator"
239230 | Some idx ->
···266257 let equal a b = Date.equal a.date b.date && Time.equal a.time b.time
267258268259 let compare a b =
269269- Date.compare a.date b.date <?> lazy (Time.compare a.time b.time)
260260+ Date.compare a.date b.date <?> Time.compare a.time b.time
270261271262 let to_string dt =
272263 Printf.sprintf "%sT%s" (Date.to_string dt.date) (Time.to_string dt.time)
···274265 let pp fmt dt = Format.pp_print_string fmt (to_string dt)
275266276267 let of_string s =
277277- let open Result_syntax in
268268+ let open Result.Syntax in
278269 match find_datetime_sep s with
279270 | None -> Error "missing date/time separator"
280271 | Some idx ->
···442433 | v -> type_error ~expected:"string" v);
443434 enc = (fun i -> Toml.String (Int64.to_string i));
444435}
445445-446446-(* ---- Internal datetime codecs (for structured datetime types) ---- *)
447447-(* These are used internally but not exposed in the mli - only ptime codecs are public *)
448448-449449-let datetime_ = {
450450- kind = "datetime";
451451- doc = "";
452452- dec = (function
453453- | Toml.Datetime s ->
454454- Result.map_error (fun msg -> Value_error msg) (Datetime.of_string s)
455455- | v -> type_error ~expected:"datetime" v);
456456- enc = (fun dt -> Toml.Datetime (Datetime.to_string dt));
457457-}
458458-459459-let datetime_local_ = {
460460- kind = "datetime-local";
461461- doc = "";
462462- dec = (function
463463- | Toml.Datetime_local s ->
464464- Result.map_error (fun msg -> Value_error msg) (Datetime_local.of_string s)
465465- | v -> type_error ~expected:"datetime-local" v);
466466- enc = (fun dt -> Toml.Datetime_local (Datetime_local.to_string dt));
467467-}
468468-469469-let date_local_ = {
470470- kind = "date-local";
471471- doc = "";
472472- dec = (function
473473- | Toml.Date_local s ->
474474- Result.map_error (fun msg -> Value_error msg) (Date.of_string s)
475475- | v -> type_error ~expected:"date-local" v);
476476- enc = (fun d -> Toml.Date_local (Date.to_string d));
477477-}
478478-479479-let time_local_ = {
480480- kind = "time-local";
481481- doc = "";
482482- dec = (function
483483- | Toml.Time_local s ->
484484- Result.map_error (fun msg -> Value_error msg) (Time.of_string s)
485485- | v -> type_error ~expected:"time-local" v);
486486- enc = (fun t -> Toml.Time_local (Time.to_string t));
487487-}
488488-489489-(* Silence unused warnings for internal codecs *)
490490-let _ = datetime_
491491-let _ = datetime_local_
492492-let _ = date_local_
493493-let _ = time_local_
494436495437(* ---- Ptime codecs ---- *)
496438···13061248 | None -> failwith "any: enc not provided");
13071249 }
1308125013091309-(* ---- Encoding and decoding ---- *)
13101310-13111251let to_tomlt_error e =
13121252 Toml.Error.make (Toml.Error.Semantic (Toml.Error.Duplicate_key (codec_error_to_string e)))
13131253···1320126013211261let encode c v = c.enc v
1322126213231323-(* Re-export the Toml module for accessing raw TOML values *)
13241263module Toml = Toml
13251264module Error = Toml.Error