···66(** Arod webserver - a tiny_httpd based server for Bushel content *)
7788open Tiny_httpd
99-open Htmlit
99+open Arod.Entries
10101111(** {1 Logging} *)
12121313let src = Logs.Src.create "arod" ~doc:"Arod webserver"
1414module Log = (val Logs.src_log src : Logs.LOG)
15151616-(** {1 Request Handlers} *)
1616+(** {1 Query Helpers} *)
17171818-let index_handler _req =
1919- let cfg = Arod.Model.get_config () in
2020- let page_content =
2121- El.div [
2222- El.h1 [El.txt cfg.site.name];
2323- El.p [El.txt cfg.site.description];
2424- El.h2 [El.txt "Recent Notes"];
2525- El.ul (
2626- List.map (fun note ->
2727- El.li [
2828- El.a ~at:[At.href (Arod.Model.Entry.site_url (`Note note))] [
2929- El.txt (Arod.Model.Note.title note)
3030- ]
3131- ]
3232- ) (List.filteri (fun i _ -> i < 10) (Arod.Model.notes ()))
3333- )
3434- ]
3535- in
3636- let html = Arod.Html.(to_page (page
3737- ~page_title:cfg.site.name
3838- ~description:cfg.site.description
3939- ~page_content
4040- ())) in
4141- Response.make_string (Ok html)
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} *)
42384343-let notes_handler _req =
4444- let cfg = Arod.Model.get_config () in
4545- let notes = Arod.Model.notes () in
4646- let page_content =
4747- El.div [
4848- El.h1 [El.txt "Notes"];
4949- El.div ~at:[At.class' "entries-list"] (
5050- List.map (fun note ->
5151- let ent = `Note note in
5252- El.article ~at:[At.class' "entry-card"] [
5353- Arod.Html.entry_href ent;
5454- Arod.Html.tags_meta ent;
5555- El.div ~at:[At.class' "entry-synopsis"] [
5656- match Arod.Model.Note.synopsis note with
5757- | Some s -> El.p [El.txt s]
5858- | None -> El.splice []
5959- ]
6060- ]
6161- ) notes
6262- )
6363- ]
6464- in
6565- let html = Arod.Html.(to_page (page
6666- ~page_title:"Notes"
6767- ~description:(Printf.sprintf "Notes from %s" cfg.site.name)
6868- ~page_content
6969- ())) in
7070- Response.make_string (Ok html)
3939+let html_response content =
4040+ Response.make_string ~headers:[("content-type", "text/html; charset=utf-8")] (Ok content)
71417272-let papers_handler _req =
7373- let cfg = Arod.Model.get_config () in
7474- let papers = Arod.Model.papers () in
7575- let page_content =
7676- El.div [
7777- El.h1 [El.txt "Papers"];
7878- El.div ~at:[At.class' "entries-list"] (
7979- List.map (fun paper ->
8080- let ent = `Paper paper in
8181- El.article ~at:[At.class' "entry-card"] [
8282- Arod.Html.entry_href ent;
8383- Arod.Html.tags_meta ent;
8484- ]
8585- ) papers
8686- )
8787- ]
8888- in
8989- let html = Arod.Html.(to_page (page
9090- ~page_title:"Papers"
9191- ~description:(Printf.sprintf "Papers by %s" cfg.site.author_name)
9292- ~page_content
9393- ())) in
9494- Response.make_string (Ok html)
4242+let not_found_response = Response.fail ~code:404 "Not Found"
95439696-let projects_handler _req =
9797- let cfg = Arod.Model.get_config () in
9898- let projects = Arod.Model.projects () in
9999- let page_content =
100100- El.div [
101101- El.h1 [El.txt "Projects"];
102102- El.div ~at:[At.class' "entries-list"] (
103103- List.map (fun project ->
104104- let ent = `Project project in
105105- El.article ~at:[At.class' "entry-card"] [
106106- Arod.Html.entry_href ent;
107107- Arod.Html.tags_meta ent;
108108- Arod.Html.full_body ent
109109- ]
110110- ) projects
111111- )
112112- ]
113113- in
114114- let html = Arod.Html.(to_page (page
115115- ~page_title:"Projects"
116116- ~description:(Printf.sprintf "Projects from %s" cfg.site.name)
117117- ~page_content
118118- ())) in
119119- Response.make_string (Ok html)
4444+let plain_response content =
4545+ Response.make_string ~headers:[("content-type", "text/plain")] (Ok content)
12046121121-let ideas_handler _req =
122122- let cfg = Arod.Model.get_config () in
123123- let ideas = Arod.Model.ideas () in
124124- let page_content =
125125- El.div [
126126- El.h1 [El.txt "Ideas"];
127127- El.div ~at:[At.class' "entries-list"] (
128128- List.map (fun idea ->
129129- let ent = `Idea idea in
130130- El.article ~at:[At.class' "entry-card"] [
131131- Arod.Html.entry_href ent;
132132- Arod.Html.tags_meta ent;
133133- Arod.Html.full_body ent
134134- ]
135135- ) ideas
136136- )
137137- ]
138138- in
139139- let html = Arod.Html.(to_page (page
140140- ~page_title:"Ideas"
141141- ~description:(Printf.sprintf "Ideas from %s" cfg.site.name)
142142- ~page_content
143143- ())) in
144144- Response.make_string (Ok html)
4747+let atom_response content =
4848+ Response.make_string ~headers:[("content-type", "application/atom+xml; charset=utf-8")] (Ok content)
14549146146-let videos_handler _req =
147147- let cfg = Arod.Model.get_config () in
148148- let videos = Arod.Model.videos () in
149149- let page_content =
150150- El.div [
151151- El.h1 [El.txt "Talks & Videos"];
152152- El.div ~at:[At.class' "entries-list"] (
153153- List.map (fun video ->
154154- let ent = `Video video in
155155- El.article ~at:[At.class' "entry-card"] [
156156- Arod.Html.entry_href ent;
157157- Arod.Html.tags_meta ent;
158158- ]
159159- ) videos
160160- )
161161- ]
162162- in
163163- let html = Arod.Html.(to_page (page
164164- ~page_title:"Talks & Videos"
165165- ~description:(Printf.sprintf "Talks and videos by %s" cfg.site.author_name)
166166- ~page_content
167167- ())) in
168168- Response.make_string (Ok html)
5050+let xml_response content =
5151+ Response.make_string ~headers:[("content-type", "application/xml")] (Ok content)
16952170170-let entry_handler slug _req =
171171- match Arod.Model.lookup slug with
172172- | None ->
173173- Response.make_string ~code:404 (Ok "Not found")
174174- | Some ent ->
175175- let cfg = Arod.Model.get_config () in
176176- let page_content =
177177- El.article ~at:[At.class' "entry-full"] [
178178- Arod.Html.entry_href ~tag:"h1" ent;
179179- Arod.Html.tags_meta ent;
180180- Arod.Html.full_body ent
181181- ]
182182- in
183183- let html = Arod.Html.(to_page (page
184184- ~page_title:(Arod.Model.Entry.title ent)
185185- ~description:(match Arod.Model.Entry.synopsis ent with Some s -> s | None -> cfg.site.description)
186186- ~page_content
187187- ())) in
188188- Response.make_string (Ok html)
5353+let json_response content =
5454+ Response.make_string ~headers:[("content-type", "application/json; charset=utf-8")] (Ok content)
18955190190-(** {1 Static File Handlers} *)
5656+(** {1 File Serving} *)
19157192192-let static_file_handler ~dir path _req =
193193- (* Remove any .. to prevent directory traversal *)
5858+let serve_file ~dir path =
19459 let clean_path =
19560 let parts = String.split_on_char '/' path in
19661 let safe_parts = List.filter (fun s -> s <> ".." && s <> ".") parts in
19762 String.concat "/" safe_parts
19863 in
19964 let file_path = Filename.concat dir clean_path in
200200- if Sys.file_exists file_path && not (Sys.is_directory file_path) then begin
201201- let ext = Filename.extension file_path in
202202- let content_type = match ext with
203203- | ".css" -> "text/css"
204204- | ".js" -> "text/javascript"
205205- | ".svg" -> "image/svg+xml"
206206- | ".png" -> "image/png"
207207- | ".jpg" | ".jpeg" -> "image/jpeg"
208208- | ".webp" -> "image/webp"
209209- | ".ico" -> "image/x-icon"
210210- | ".woff" -> "font/woff"
211211- | ".woff2" -> "font/woff2"
212212- | ".pdf" -> "application/pdf"
213213- | ".json" -> "application/json"
214214- | ".xml" -> "application/xml"
215215- | ".html" -> "text/html"
216216- | _ -> "application/octet-stream"
217217- in
218218- let ic = open_in_bin file_path in
219219- let content = really_input_string ic (in_channel_length ic) in
220220- close_in ic;
221221- Response.make_string ~headers:[("Content-Type", content_type)] (Ok content)
222222- end else
223223- Response.make_string ~code:404 (Ok "Not found")
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"
224159225160(** {1 Feed Handlers} *)
226161227227-let atom_handler _req =
228228- (* TODO: implement Atom feed generation *)
229229- Response.make_string
230230- ~headers:[("Content-Type", "application/atom+xml")]
231231- (Ok {|<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title>Feed</title></feed>|})
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
232169233233-let json_feed_handler _req =
234234- (* TODO: implement JSON feed generation *)
235235- Response.make_string
236236- ~headers:[("Content-Type", "application/feed+json")]
237237- (Ok {|{"version":"https://jsonfeed.org/version/1.1","title":"Feed","items":[]}|})
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
238178239239-(** {1 Logging Middleware} *)
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
240185241241-let logging_middleware handler req =
242242- let start = Unix.gettimeofday () in
243243- let resp = handler req in
244244- let elapsed = Unix.gettimeofday () -. start in
245245- Log.info (fun m -> m "%s %s %.3fs"
246246- (Tiny_httpd.Meth.to_string (Request.meth req))
247247- (Request.path req)
248248- elapsed);
249249- resp
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
250292251293(** {1 Server Setup} *)
252294253295let setup_routes server cfg =
254254- (* Index *)
255255- Server.add_route_handler server Route.(exact "/" @/ return) index_handler;
296296+ let open Route in
256297257257- (* Entry lists *)
258258- Server.add_route_handler server Route.(exact "/notes" @/ return) notes_handler;
259259- Server.add_route_handler server Route.(exact "/papers" @/ return) papers_handler;
260260- Server.add_route_handler server Route.(exact "/projects" @/ return) projects_handler;
261261- Server.add_route_handler server Route.(exact "/ideas" @/ return) ideas_handler;
262262- Server.add_route_handler server Route.(exact "/videos" @/ return) videos_handler;
263263- Server.add_route_handler server Route.(exact "/talks" @/ return) videos_handler;
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;
264302265265- (* Individual entries *)
266266- Server.add_route_handler server Route.(exact "/notes" @/ string_urlencoded @/ return) entry_handler;
267267- Server.add_route_handler server Route.(exact "/papers" @/ string_urlencoded @/ return) entry_handler;
268268- Server.add_route_handler server Route.(exact "/projects" @/ string_urlencoded @/ return) entry_handler;
269269- Server.add_route_handler server Route.(exact "/ideas" @/ string_urlencoded @/ return) entry_handler;
270270- Server.add_route_handler server Route.(exact "/videos" @/ string_urlencoded @/ return) entry_handler;
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);
271309272272- (* Static files *)
273273- Server.add_route_handler server
274274- Route.(exact_path "/assets" rest_of_path_urlencoded)
275275- (static_file_handler ~dir:cfg.Arod.Config.paths.assets_dir);
276276- Server.add_route_handler server
277277- Route.(exact_path "/images" rest_of_path_urlencoded)
278278- (static_file_handler ~dir:cfg.paths.images_dir);
279279- Server.add_route_handler server
280280- Route.(exact_path "/static" rest_of_path_urlencoded)
281281- (static_file_handler ~dir:cfg.paths.static_dir);
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);
282315283283- (* Feeds *)
284284- Server.add_route_handler server Route.(exact "/news.xml" @/ return) atom_handler;
285285- Server.add_route_handler server Route.(exact "/feed.json" @/ return) json_feed_handler;
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);
286380287381 ()
288382···312406 Eio_main.run @@ fun env ->
313407 let fs = Eio.Stdenv.fs env in
314408315315- (* Load entries *)
316409 Log.info (fun m -> m "Loading entries from %s" cfg.paths.data_dir);
317410 let _entries = Arod.Model.init ~cfg fs in
318411 Log.info (fun m -> m "Loaded %d notes, %d papers, %d projects, %d ideas, %d videos, %d images"
···323416 (List.length (Arod.Model.videos ()))
324417 (List.length (Arod.Model.images ())));
325418326326- (* Create server *)
327419 let server = Tiny_httpd.create ~addr:cfg.server.host ~port:cfg.server.port () in
328328- Tiny_httpd.add_middleware server ~stage:(`Stage 0) logging_middleware;
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+329432 setup_routes server cfg;
330433331434 Log.app (fun m -> m "Listening on http://%s:%d" cfg.server.host cfg.server.port);
+40-2
arod/lib/arod.ml
···12121313 - {!Config} - TOML configuration
1414 - {!Model} - Bushel bridge layer
1515- - {!Html} - HTML generation with htmlit *)
1515+ - {!View} - Core rendering utilities
1616+ - {!Page} - Page layout
1717+ - {!Entries} - Entry type filtering and rendering *)
16181719module Config = Arod_config
1820(** TOML-based configuration for the webserver. *)
···2022module Model = Arod_model
2123(** Model layer bridging Bushel to the webserver. *)
22242525+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+2361module Html = Arod_html
2424-(** HTML generation using htmlit. *)
6262+(** Legacy HTML generation (for compatibility). *)
+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, 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+(** 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 ()
+194
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+ (match P.best_url p with
118118+ | Some url when String.ends_with ~suffix:".pdf" url ->
119119+ let pdf_url = form_uri cfg url in
120120+ let pdf_title = P.title p in
121121+ [J.Attachment.create ~url:pdf_url ~mime_type:"application/pdf" ~title:pdf_title ()]
122122+ | _ -> [])
123123+ | _ -> [])
124124+ | None -> []
125125+ in
126126+127127+ let references =
128128+ let me = match Arod_model.lookup_by_handle cfg.Arod_config.site.author_handle with
129129+ | Some c -> c
130130+ | None -> failwith "Author not found"
131131+ in
132132+ Bushel.Md.note_references (Arod_model.get_entries ()) me note
133133+ |> List.map (fun (doi, _citation, ref_source) ->
134134+ let doi_url = Printf.sprintf "https://doi.org/%s" doi in
135135+ let cito = match ref_source with
136136+ | Bushel.Md.Paper -> [`CitesAsSourceDocument]
137137+ | Bushel.Md.Note -> [`CitesAsRelated]
138138+ | Bushel.Md.External -> [`Cites]
139139+ in
140140+ J.Reference.create ~url:doi_url ~doi ~cito ()
141141+ )
142142+ in
143143+144144+ let json_author = author cfg (Arod_model.lookup_by_handle cfg.site.author_handle |> Option.get) in
145145+146146+ Jsonfeed.Item.create
147147+ ~id
148148+ ~content
149149+ ~url
150150+ ?external_url
151151+ ?image
152152+ ?summary
153153+ ~title
154154+ ~date_published
155155+ ~date_modified
156156+ ~authors:[json_author]
157157+ ~tags
158158+ ~attachments
159159+ ~references
160160+ ()
161161+162162+let item_of_entry cfg (e:Arod_model.Entry.entry) =
163163+ match e with
164164+ | `Note n -> Some (item_of_note cfg n)
165165+ | _ -> None
166166+167167+let feed cfg uri entries =
168168+ let title = cfg.Arod_config.site.name ^ "'s feed" in
169169+ let home_page_url = cfg.site.base_url in
170170+ let feed_url = form_uri cfg uri in
171171+ let icon = cfg.site.base_url ^ "/assets/favicon.ico" in
172172+ let json_author = author cfg (Arod_model.lookup_by_handle cfg.site.author_handle |> Option.get) in
173173+ let authors = [json_author] in
174174+ let language = "en-US" in
175175+176176+ let items = List.filter_map (item_of_entry cfg) entries in
177177+178178+ Jsonfeed.create
179179+ ~title
180180+ ~home_page_url
181181+ ~feed_url
182182+ ~icon
183183+ ~authors
184184+ ~language
185185+ ~items
186186+ ()
187187+188188+let feed_string cfg uri entries =
189189+ let f = feed cfg uri entries in
190190+ match Jsonfeed.to_string f with
191191+ | Ok s -> s
192192+ | Error e ->
193193+ let msg = Fmt.str "Failed to encode JSON Feed: %a" Jsont.Error.pp e in
194194+ failwith msg
+39-24
arod/lib/arod_model.ml
···1616module Md = Bushel.Md
1717module Util = Bushel.Util
1818module Img = Srcsetter
1919+module Contact = Sortal_schema.Contact
19202021(** {1 Global State} *)
2122···100101 let open Cmarkit_renderer.Context in
101102 let inline c = function
102103 | Inline.Image (img, _meta) ->
103103- (* Handle bushel image syntax *)
104104+ (* Handle bushel image syntax - :slug format *)
104105 (match Inline.Link.reference img with
105106 | `Inline (ld, _) ->
106107 (match Link_definition.dest ld with
···138139 | Some img -> "/images/" ^ Img.name img
139140 | None -> "/images/" ^ slug ^ ".webp"
140141 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
141152 (* Check for positioning directive *)
142153 (match caption with
143154 | "%c" | "%r" | "%lc" | "%rc" ->
···148159 | "%rc" -> "image-right-float"
149160 | _ -> "image-center"
150161 in
151151- let srcset_attr = match img_info with
152152- | Some img ->
153153- let variants = Img.variants img in
154154- let parts = Img.MS.fold (fun name (w, _) acc ->
155155- Printf.sprintf "/images/%s %dw" name w :: acc
156156- ) variants [] in
157157- if parts = [] then ""
158158- else Printf.sprintf " srcset=\"%s\"" (String.concat ", " parts)
159159- | None -> ""
160160- in
161162 let html = Printf.sprintf
162162- {|<figure class="%s"><img src="%s" alt="%s" title="%s" loading="lazy"%s><figcaption>%s</figcaption></figure>|}
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>|}
163164 fig_class dest title title srcset_attr title
164165 in
165166 string c html;
166167 true
167168 | _ ->
168168- (* Regular image *)
169169- let srcset_attr = match img_info with
170170- | Some img ->
171171- let variants = Img.variants img in
172172- let parts = Img.MS.fold (fun name (w, _) acc ->
173173- Printf.sprintf "/images/%s %dw" name w :: acc
174174- ) variants [] in
175175- if parts = [] then ""
176176- else Printf.sprintf " srcset=\"%s\"" (String.concat ", " parts)
177177- | None -> ""
178178- in
169169+ (* Regular image with content-image class for lightbox *)
179170 let html = Printf.sprintf
180180- {|<img src="%s" alt="%s" title="%s" loading="lazy"%s>|}
171171+ {|<img class="content-image" src="%s" alt="%s" title="%s" loading="lazy"%s sizes="(max-width: 768px) 100vw, 33vw">|}
181172 dest caption title srcset_attr
182173 in
183174 string c html;
···205196206197let concat_tags tags1 tags2 =
207198 tags1 @ (List.filter (fun t -> not (List.mem t 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+ ]
···194194 | `Slug t -> lk t
195195 | _ -> None
196196 ) tags
197197+198198+(** {1 Thumbnail Functions} *)
199199+200200+(** Get the smallest webp variant from a srcsetter image - prefers size just above 480px *)
201201+let smallest_webp_variant img =
202202+ let variants = Srcsetter.variants img in
203203+ let webp_variants =
204204+ Srcsetter.MS.bindings variants
205205+ |> List.filter (fun (name, _) -> String.ends_with ~suffix:".webp" name)
206206+ in
207207+ match webp_variants with
208208+ | [] ->
209209+ (* No webp variants - use the name field which is always webp *)
210210+ "/images/" ^ Srcsetter.name img
211211+ | variants ->
212212+ (* Prefer variants with width > 480px, choosing the smallest one above 480 *)
213213+ let large_variants = List.filter (fun (_, (w, _)) -> w > 480) variants in
214214+ let candidates = if large_variants = [] then variants else large_variants in
215215+ (* Find the smallest variant from candidates *)
216216+ let smallest = List.fold_left (fun acc (name, (w, h)) ->
217217+ match acc with
218218+ | None -> Some (name, w, h)
219219+ | Some (_, min_w, _) when w < min_w -> Some (name, w, h)
220220+ | _ -> acc
221221+ ) None candidates in
222222+ match smallest with
223223+ | Some (name, _, _) -> "/images/" ^ name
224224+ | None -> "/images/" ^ Srcsetter.name img
225225+226226+(** Get thumbnail slug for a contact *)
227227+let contact_thumbnail_slug contact =
228228+ (* Contact images use just the handle as slug *)
229229+ Some (Sortal_schema.Contact.handle contact)
230230+231231+(** Get thumbnail URL for a contact - resolved through srcsetter *)
232232+let contact_thumbnail entries contact =
233233+ match contact_thumbnail_slug contact with
234234+ | None -> None
235235+ | Some thumb_slug ->
236236+ match lookup_image entries thumb_slug with
237237+ | Some img -> Some (smallest_webp_variant img)
238238+ | None -> None
239239+240240+(** Get thumbnail slug for an entry - simple version *)
241241+let rec thumbnail_slug entries = function
242242+ | `Paper p -> Some (Bushel_paper.slug p)
243243+ | `Video v -> Some (Bushel_video.uuid v)
244244+ | `Project p -> Some (Printf.sprintf "project-%s" (Bushel_project.slug p))
245245+ | `Idea i ->
246246+ (* Use project thumbnail for ideas *)
247247+ let project_slug = Bushel_idea.project i in
248248+ (match lookup entries project_slug with
249249+ | Some p -> thumbnail_slug entries p
250250+ | None -> None)
251251+ | `Note n ->
252252+ (* Use titleimage if set, otherwise try slug_ent's thumbnail *)
253253+ (match Bushel_note.titleimage n with
254254+ | Some slug -> Some slug
255255+ | None ->
256256+ match Bushel_note.slug_ent n with
257257+ | Some slug_ent ->
258258+ (match lookup entries slug_ent with
259259+ | Some entry -> thumbnail_slug entries entry
260260+ | None -> None)
261261+ | None -> None)
262262+263263+(** Get thumbnail URL for an entry with fallbacks - resolved through srcsetter *)
264264+let thumbnail entries entry =
265265+ match thumbnail_slug entries entry with
266266+ | None -> None
267267+ | Some thumb_slug ->
268268+ match lookup_image entries thumb_slug with
269269+ | Some img -> Some (smallest_webp_variant img)
270270+ | None -> None
+17
ocaml-bushel/lib/bushel_entry.mli
···127127128128val mention_entries : t -> Bushel_tags.t list -> entry list
129129(** [mention_entries entries tags] returns entries mentioned in the tags. *)
130130+131131+(** {1 Thumbnail Functions} *)
132132+133133+val smallest_webp_variant : Srcsetter.t -> string
134134+(** [smallest_webp_variant img] returns URL path to smallest webp variant above 480px. *)
135135+136136+val contact_thumbnail_slug : Sortal_schema.Contact.t -> string option
137137+(** [contact_thumbnail_slug contact] returns the image slug for a contact. *)
138138+139139+val contact_thumbnail : t -> Sortal_schema.Contact.t -> string option
140140+(** [contact_thumbnail entries contact] returns the thumbnail URL for a contact. *)
141141+142142+val thumbnail_slug : t -> entry -> string option
143143+(** [thumbnail_slug entries entry] returns the image slug for an entry. *)
144144+145145+val thumbnail : t -> entry -> string option
146146+(** [thumbnail entries entry] returns the thumbnail URL for an entry. *)
+263
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 title = Inline.Link.text lb |> text_of_inline in
220220+ let txt = Inline.Text (title, meta) in
221221+ let ld = Link_definition.make ~dest:(slug, 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+ (* TODO: Add external DOI URL scanning and publisher URL resolution *)
886886+ (* This requires DOI caching infrastructure which is not yet ported *)
887887+888888+ List.rev !refs