···1+(*---------------------------------------------------------------------------
2+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
3+ SPDX-License-Identifier: ISC
4+ ---------------------------------------------------------------------------*)
5+6+(** Atom feed generation for Arod webserver *)
7+8+module E = Arod_model.Entry
9+module N = Arod_model.Note
10+module C = Sortal_schema.Contact
11+module X = Syndic.Atom
12+13+let anil_copyright = "(c) 1998-2025, all rights reserved"
14+15+let author c =
16+ let uri = Option.map Uri.of_string (C.best_url c) in
17+ let email = match C.emails c with e :: _ -> Some e.C.address | [] -> None in
18+ {X.name=(C.name c); email; uri}
19+20+let form_uri cfg path = Uri.of_string (cfg.Arod_config.site.base_url ^ path)
21+22+let atom_id cfg e = form_uri cfg @@ E.site_url e
23+24+let generator = {
25+ X.version = Some "1.0";
26+ uri = Some (Uri.of_string "https://github.com/avsm/bushel");
27+ content = "Bushel"
28+}
29+30+let link cfg e =
31+ let href = form_uri cfg @@ E.site_url e in
32+ let rel = X.Self in
33+ let type_media = None in
34+ let title = E.title e in
35+ let length = None in
36+ let hreflang = None in
37+ {X.href; rel; type_media; title; length; hreflang}
38+39+let news_feed_link cfg =
40+ let href = form_uri cfg "/news.xml" in
41+ let rel = X.Self in
42+ let type_media = None in
43+ let title = cfg.Arod_config.site.name in
44+ let length = None in
45+ let hreflang = None in
46+ {X.href; rel; type_media; title; length; hreflang}
47+48+let ext_link ~title l =
49+ let href = Uri.of_string l in
50+ let rel = X.Alternate in
51+ let type_media = None in
52+ let title = title in
53+ let length = None in
54+ let hreflang = None in
55+ [{X.href; rel; type_media; title; length; hreflang}]
56+57+let atom_of_note cfg ~author note =
58+ let e = `Note note in
59+ let id = atom_id cfg e in
60+ let categories = List.map (fun tag ->
61+ X.category tag
62+ ) (N.tags note) in
63+ let rights : X.title = X.Text anil_copyright in
64+ let source = None in
65+ let title : X.title = X.Text note.N.title in
66+ let published = N.origdate note in
67+ let updated = N.datetime note in
68+ let authors = author, [] in
69+70+ let base_html = Arod_view.md_to_atom_html note.N.body in
71+72+ let is_perma = N.perma note in
73+ let has_doi = match N.doi note with Some _ -> true | None -> false in
74+ let html_with_refs =
75+ if is_perma || has_doi then
76+ let me = match Arod_model.lookup_by_handle cfg.Arod_config.site.author_handle with
77+ | Some c -> c
78+ | None -> failwith "Author not found"
79+ in
80+ let references = Bushel.Md.note_references (Arod_model.get_entries ()) me note in
81+ if List.length references > 0 then
82+ let refs_html =
83+ let ref_items = List.map (fun (doi, citation, _) ->
84+ let doi_url = Printf.sprintf "https://doi.org/%s" doi in
85+ Printf.sprintf "<li>%s<a href=\"%s\" target=\"_blank\"><i>%s</i></a></li>"
86+ citation doi_url doi
87+ ) references |> String.concat "\n" in
88+ Printf.sprintf "<h1>References</h1><ul>%s</ul>" ref_items
89+ in
90+ base_html ^ refs_html
91+ else
92+ base_html
93+ else
94+ base_html
95+ in
96+97+ let html_base_uri = Some (Uri.of_string (cfg.site.base_url ^ "/")) in
98+ let content, links =
99+ match N.link note with
100+ | `Local _ ->
101+ let content = Some (X.Html (html_base_uri, html_with_refs)) in
102+ let links = [link cfg e] in
103+ content, links
104+ | `Ext (_l,u) ->
105+ let content = Some (X.Html (html_base_uri, html_with_refs)) in
106+ let links = ext_link ~title:note.N.title u in
107+ content, links
108+ in
109+ let entry = Syndic.Atom.entry
110+ ~categories ~links ~published ~rights ?content
111+ ?source ~title ~updated
112+ ~id ~authors ()
113+ in
114+ entry
115+116+let atom_of_entry cfg ~author (e:Arod_model.Entry.entry) =
117+ match e with
118+ | `Note n -> Some (atom_of_note cfg ~author n)
119+ | _ -> None
120+121+let feed cfg uri entries =
122+ try
123+ let author = author @@ (Arod_model.lookup_by_handle cfg.Arod_config.site.author_handle |> Option.get) in
124+ let authors = [author] in
125+ let icon = Uri.of_string (cfg.site.base_url ^ "/assets/favicon.ico") in
126+ let links = [news_feed_link cfg] in
127+ let atom_entries = List.filter_map (atom_of_entry cfg ~author) entries in
128+ let title : X.text_construct = X.Text (cfg.site.name ^ "'s feed") in
129+ let updated = Arod_model.Entry.datetime (List.hd entries) in
130+ let id = form_uri cfg uri in
131+ let rights : X.title = X.Text anil_copyright in
132+ X.feed ~id ~rights ~authors ~title ~updated ~icon ~links atom_entries
133+ with exn -> Printexc.print_backtrace stdout; print_endline "x"; raise exn
134+135+let feed_string cfg uri f =
136+ let buf = Buffer.create 1024 in
137+ X.output (feed cfg uri f) (`Buffer buf);
138+ Buffer.contents buf
···1+(*---------------------------------------------------------------------------
2+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
3+ SPDX-License-Identifier: ISC
4+ ---------------------------------------------------------------------------*)
5+6+(** JSON feed generation for Arod webserver *)
7+8+module E = Arod_model.Entry
9+module N = Arod_model.Note
10+module C = Sortal_schema.Contact
11+module P = Arod_model.Paper
12+module J = Jsonfeed
13+14+let form_uri cfg path = cfg.Arod_config.site.base_url ^ path
15+16+let author cfg c =
17+ let name = C.name c in
18+ let url = match C.orcid c with
19+ | Some orcid -> Some (Printf.sprintf "https://orcid.org/%s" orcid)
20+ | None -> C.best_url c
21+ in
22+ let avatar = Some (form_uri cfg "/images/anil-headshot.webp") in
23+ Jsonfeed.Author.create ?name:(Some name) ?url ?avatar ()
24+25+let item_of_note cfg note =
26+ let e = `Note note in
27+ let id = match N.doi note with
28+ | Some doi ->
29+ let is_valid_doi =
30+ not (String.contains doi ' ') &&
31+ not (String.contains doi '\t') &&
32+ not (String.contains doi '\n') &&
33+ String.length doi > 0
34+ in
35+ if is_valid_doi then
36+ Printf.sprintf "https://doi.org/%s" doi
37+ else
38+ let note_title = N.title note in
39+ failwith (Printf.sprintf "Invalid DOI in note '%s': '%s'" note_title doi)
40+ | None -> form_uri cfg (E.site_url e)
41+ in
42+ let url = form_uri cfg (E.site_url e) in
43+ let title = N.title note in
44+ let date_published = N.origdate note in
45+ let date_modified = N.datetime note in
46+ let tags = N.tags note in
47+48+ let base_html = Arod_view.md_to_atom_html note.N.body in
49+50+ let is_perma = N.perma note in
51+ let has_doi = match N.doi note with Some _ -> true | None -> false in
52+ let html_with_refs =
53+ if is_perma || has_doi then
54+ let me = match Arod_model.lookup_by_handle cfg.Arod_config.site.author_handle with
55+ | Some c -> c
56+ | None -> failwith "Author not found"
57+ in
58+ let references = Bushel.Md.note_references (Arod_model.get_entries ()) me note in
59+ if List.length references > 0 then
60+ let refs_html =
61+ let ref_items = List.map (fun (doi, citation, _) ->
62+ let doi_url = Printf.sprintf "https://doi.org/%s" doi in
63+ Printf.sprintf "<li>%s<a href=\"%s\" target=\"_blank\"><i>%s</i></a></li>"
64+ citation doi_url doi
65+ ) references |> String.concat "\n" in
66+ Printf.sprintf "<h1>References</h1><ul>%s</ul>" ref_items
67+ in
68+ base_html ^ refs_html
69+ else
70+ base_html
71+ else
72+ base_html
73+ in
74+ let content = `Html html_with_refs in
75+76+ let external_url = match note.N.via with
77+ | Some (_title, via_url) -> Some via_url
78+ | None ->
79+ match N.link note with
80+ | `Local _ -> None
81+ | `Ext (_l, u) -> Some u
82+ in
83+84+ let image = match note.N.titleimage with
85+ | Some img_slug ->
86+ (try
87+ let entries = Arod_model.get_entries () in
88+ (match E.lookup_image entries img_slug with
89+ | Some img_ent ->
90+ let target_width = 1280 in
91+ let open Arod_model.Img in
92+ let variants = MS.bindings img_ent.variants in
93+ let best_variant =
94+ match variants with
95+ | [] ->
96+ Printf.sprintf "%s.webp" (Filename.chop_extension (origin img_ent))
97+ | _ ->
98+ let sorted = List.sort (fun (_f1,(w1,_h1)) (_f2,(w2,_h2)) ->
99+ let diff1 = abs (w1 - target_width) in
100+ let diff2 = abs (w2 - target_width) in
101+ compare diff1 diff2
102+ ) variants in
103+ fst (List.hd sorted)
104+ in
105+ Some (Printf.sprintf "%s/images/%s" cfg.Arod_config.site.base_url best_variant)
106+ | None -> None)
107+ with Not_found -> None)
108+ | None -> None
109+ in
110+111+ let summary = note.N.synopsis in
112+113+ let attachments = match N.slug_ent note with
114+ | Some slug ->
115+ (match Arod_model.lookup slug with
116+ | Some (`Paper p) ->
117+ (match P.best_url p with
118+ | Some url when String.ends_with ~suffix:".pdf" url ->
119+ let pdf_url = form_uri cfg url in
120+ let pdf_title = P.title p in
121+ [J.Attachment.create ~url:pdf_url ~mime_type:"application/pdf" ~title:pdf_title ()]
122+ | _ -> [])
123+ | _ -> [])
124+ | None -> []
125+ in
126+127+ let references =
128+ let me = match Arod_model.lookup_by_handle cfg.Arod_config.site.author_handle with
129+ | Some c -> c
130+ | None -> failwith "Author not found"
131+ in
132+ Bushel.Md.note_references (Arod_model.get_entries ()) me note
133+ |> List.map (fun (doi, _citation, ref_source) ->
134+ let doi_url = Printf.sprintf "https://doi.org/%s" doi in
135+ let cito = match ref_source with
136+ | Bushel.Md.Paper -> [`CitesAsSourceDocument]
137+ | Bushel.Md.Note -> [`CitesAsRelated]
138+ | Bushel.Md.External -> [`Cites]
139+ in
140+ J.Reference.create ~url:doi_url ~doi ~cito ()
141+ )
142+ in
143+144+ let json_author = author cfg (Arod_model.lookup_by_handle cfg.site.author_handle |> Option.get) in
145+146+ Jsonfeed.Item.create
147+ ~id
148+ ~content
149+ ~url
150+ ?external_url
151+ ?image
152+ ?summary
153+ ~title
154+ ~date_published
155+ ~date_modified
156+ ~authors:[json_author]
157+ ~tags
158+ ~attachments
159+ ~references
160+ ()
161+162+let item_of_entry cfg (e:Arod_model.Entry.entry) =
163+ match e with
164+ | `Note n -> Some (item_of_note cfg n)
165+ | _ -> None
166+167+let feed cfg uri entries =
168+ let title = cfg.Arod_config.site.name ^ "'s feed" in
169+ let home_page_url = cfg.site.base_url in
170+ let feed_url = form_uri cfg uri in
171+ let icon = cfg.site.base_url ^ "/assets/favicon.ico" in
172+ let json_author = author cfg (Arod_model.lookup_by_handle cfg.site.author_handle |> Option.get) in
173+ let authors = [json_author] in
174+ let language = "en-US" in
175+176+ let items = List.filter_map (item_of_entry cfg) entries in
177+178+ Jsonfeed.create
179+ ~title
180+ ~home_page_url
181+ ~feed_url
182+ ~icon
183+ ~authors
184+ ~language
185+ ~items
186+ ()
187+188+let feed_string cfg uri entries =
189+ let f = feed cfg uri entries in
190+ match Jsonfeed.to_string f with
191+ | Ok s -> s
192+ | Error e ->
193+ let msg = Fmt.str "Failed to encode JSON Feed: %a" Jsont.Error.pp e in
194+ failwith msg
+39-24
arod/lib/arod_model.ml
···16module Md = Bushel.Md
17module Util = Bushel.Util
18module Img = Srcsetter
01920(** {1 Global State} *)
21···100 let open Cmarkit_renderer.Context in
101 let inline c = function
102 | Inline.Image (img, _meta) ->
103- (* Handle bushel image syntax *)
104 (match Inline.Link.reference img with
105 | `Inline (ld, _) ->
106 (match Link_definition.dest ld with
···138 | Some img -> "/images/" ^ Img.name img
139 | None -> "/images/" ^ slug ^ ".webp"
140 in
0000000000141 (* Check for positioning directive *)
142 (match caption with
143 | "%c" | "%r" | "%lc" | "%rc" ->
···148 | "%rc" -> "image-right-float"
149 | _ -> "image-center"
150 in
151- let srcset_attr = match img_info with
152- | Some img ->
153- let variants = Img.variants img in
154- let parts = Img.MS.fold (fun name (w, _) acc ->
155- Printf.sprintf "/images/%s %dw" name w :: acc
156- ) variants [] in
157- if parts = [] then ""
158- else Printf.sprintf " srcset=\"%s\"" (String.concat ", " parts)
159- | None -> ""
160- in
161 let html = Printf.sprintf
162- {|<figure class="%s"><img src="%s" alt="%s" title="%s" loading="lazy"%s><figcaption>%s</figcaption></figure>|}
163 fig_class dest title title srcset_attr title
164 in
165 string c html;
166 true
167 | _ ->
168- (* Regular image *)
169- let srcset_attr = match img_info with
170- | Some img ->
171- let variants = Img.variants img in
172- let parts = Img.MS.fold (fun name (w, _) acc ->
173- Printf.sprintf "/images/%s %dw" name w :: acc
174- ) variants [] in
175- if parts = [] then ""
176- else Printf.sprintf " srcset=\"%s\"" (String.concat ", " parts)
177- | None -> ""
178- in
179 let html = Printf.sprintf
180- {|<img src="%s" alt="%s" title="%s" loading="lazy"%s>|}
181 dest caption title srcset_attr
182 in
183 string c html;
···205206let concat_tags tags1 tags2 =
207 tags1 @ (List.filter (fun t -> not (List.mem t tags1)) tags2)
000000000000000000000000
···16module Md = Bushel.Md
17module Util = Bushel.Util
18module Img = Srcsetter
19+module Contact = Sortal_schema.Contact
2021(** {1 Global State} *)
22···101 let open Cmarkit_renderer.Context in
102 let inline c = function
103 | Inline.Image (img, _meta) ->
104+ (* Handle bushel image syntax - :slug format *)
105 (match Inline.Link.reference img with
106 | `Inline (ld, _) ->
107 (match Link_definition.dest ld with
···139 | Some img -> "/images/" ^ Img.name img
140 | None -> "/images/" ^ slug ^ ".webp"
141 in
142+ let srcset_attr = match img_info with
143+ | Some img ->
144+ let variants = Img.variants img in
145+ let parts = Img.MS.fold (fun name (w, _) acc ->
146+ Printf.sprintf "/images/%s %dw" name w :: acc
147+ ) variants [] in
148+ if parts = [] then ""
149+ else Printf.sprintf " srcset=\"%s\"" (String.concat ", " parts)
150+ | None -> ""
151+ in
152 (* Check for positioning directive *)
153 (match caption with
154 | "%c" | "%r" | "%lc" | "%rc" ->
···159 | "%rc" -> "image-right-float"
160 | _ -> "image-center"
161 in
0000000000162 let html = Printf.sprintf
163+ {|<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>|}
164 fig_class dest title title srcset_attr title
165 in
166 string c html;
167 true
168 | _ ->
169+ (* Regular image with content-image class for lightbox *)
0000000000170 let html = Printf.sprintf
171+ {|<img class="content-image" src="%s" alt="%s" title="%s" loading="lazy"%s sizes="(max-width: 768px) 100vw, 33vw">|}
172 dest caption title srcset_attr
173 in
174 string c html;
···196197let concat_tags tags1 tags2 =
198 tags1 @ (List.filter (fun t -> not (List.mem t tags1)) tags2)
199+200+(** Count tags across all entries *)
201+let count_tags_for_ents entries =
202+ let counts = Hashtbl.create 32 in
203+ List.iter (fun ent ->
204+ let tags = Entry.tags_of_ent (get_entries ()) ent in
205+ List.iter (fun tag ->
206+ let current = Hashtbl.find_opt counts tag |> Option.value ~default:0 in
207+ Hashtbl.replace counts tag (current + 1)
208+ ) tags
209+ ) entries;
210+ counts
211+212+(** Get category tags with counts for the header navigation *)
213+let cats () =
214+ let entries = all_entries () in
215+ let counts = count_tags_for_ents entries in
216+ Hashtbl.fold (fun k v acc ->
217+ match k with
218+ | `Set "videos" -> acc (* Skip videos, use talks instead *)
219+ | `Set _ -> (k, v) :: acc
220+ | _ -> acc
221+ ) counts []
222+ |> List.sort (fun (a, _) (b, _) -> compare (Tags.to_string a) (Tags.to_string b))
+32
arod/lib/arod_notes.ml
···00000000000000000000000000000000
···1+(*---------------------------------------------------------------------------
2+ Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>. All rights reserved.
3+ SPDX-License-Identifier: ISC
4+ ---------------------------------------------------------------------------*)
5+6+(** Note rendering for Arod webserver *)
7+8+open Htmlit
9+10+let note_for_feed n =
11+ let (body_html, word_count_info) = Arod_view.truncated_body (`Note n) in
12+ (body_html, word_count_info)
13+14+let one_note_brief n =
15+ let (body_html, word_count_info) = Arod_view.truncated_body (`Note n) in
16+ (El.splice [
17+ Arod_view.entry_href (`Note n);
18+ body_html
19+ ], word_count_info)
20+21+let one_note_full n =
22+ let body = Arod_model.Note.body n in
23+ let body_with_ref = match Arod_model.Note.slug_ent n with
24+ | None -> body
25+ | Some slug_ent ->
26+ let parent_ent = Arod_model.lookup_exn slug_ent in
27+ let parent_title = Arod_model.Entry.title parent_ent in
28+ body ^ "\n\nRead more about [" ^ parent_title ^ "](:" ^ slug_ent ^ ")."
29+ in
30+ El.div ~at:[At.class' "note"] [
31+ El.unsafe_raw (Arod_view.md_to_html body_with_ref)
32+ ]
···194 | `Slug t -> lk t
195 | _ -> None
196 ) tags
00000000000000000000000000000000000000000000000000000000000000000000000000
···194 | `Slug t -> lk t
195 | _ -> None
196 ) tags
197+198+(** {1 Thumbnail Functions} *)
199+200+(** Get the smallest webp variant from a srcsetter image - prefers size just above 480px *)
201+let smallest_webp_variant img =
202+ let variants = Srcsetter.variants img in
203+ let webp_variants =
204+ Srcsetter.MS.bindings variants
205+ |> List.filter (fun (name, _) -> String.ends_with ~suffix:".webp" name)
206+ in
207+ match webp_variants with
208+ | [] ->
209+ (* No webp variants - use the name field which is always webp *)
210+ "/images/" ^ Srcsetter.name img
211+ | variants ->
212+ (* Prefer variants with width > 480px, choosing the smallest one above 480 *)
213+ let large_variants = List.filter (fun (_, (w, _)) -> w > 480) variants in
214+ let candidates = if large_variants = [] then variants else large_variants in
215+ (* Find the smallest variant from candidates *)
216+ let smallest = List.fold_left (fun acc (name, (w, h)) ->
217+ match acc with
218+ | None -> Some (name, w, h)
219+ | Some (_, min_w, _) when w < min_w -> Some (name, w, h)
220+ | _ -> acc
221+ ) None candidates in
222+ match smallest with
223+ | Some (name, _, _) -> "/images/" ^ name
224+ | None -> "/images/" ^ Srcsetter.name img
225+226+(** Get thumbnail slug for a contact *)
227+let contact_thumbnail_slug contact =
228+ (* Contact images use just the handle as slug *)
229+ Some (Sortal_schema.Contact.handle contact)
230+231+(** Get thumbnail URL for a contact - resolved through srcsetter *)
232+let contact_thumbnail entries contact =
233+ match contact_thumbnail_slug contact with
234+ | None -> None
235+ | Some thumb_slug ->
236+ match lookup_image entries thumb_slug with
237+ | Some img -> Some (smallest_webp_variant img)
238+ | None -> None
239+240+(** Get thumbnail slug for an entry - simple version *)
241+let rec thumbnail_slug entries = function
242+ | `Paper p -> Some (Bushel_paper.slug p)
243+ | `Video v -> Some (Bushel_video.uuid v)
244+ | `Project p -> Some (Printf.sprintf "project-%s" (Bushel_project.slug p))
245+ | `Idea i ->
246+ (* Use project thumbnail for ideas *)
247+ let project_slug = Bushel_idea.project i in
248+ (match lookup entries project_slug with
249+ | Some p -> thumbnail_slug entries p
250+ | None -> None)
251+ | `Note n ->
252+ (* Use titleimage if set, otherwise try slug_ent's thumbnail *)
253+ (match Bushel_note.titleimage n with
254+ | Some slug -> Some slug
255+ | None ->
256+ match Bushel_note.slug_ent n with
257+ | Some slug_ent ->
258+ (match lookup entries slug_ent with
259+ | Some entry -> thumbnail_slug entries entry
260+ | None -> None)
261+ | None -> None)
262+263+(** Get thumbnail URL for an entry with fallbacks - resolved through srcsetter *)
264+let thumbnail entries entry =
265+ match thumbnail_slug entries entry with
266+ | None -> None
267+ | Some thumb_slug ->
268+ match lookup_image entries thumb_slug with
269+ | Some img -> Some (smallest_webp_variant img)
270+ | None -> None
+17
ocaml-bushel/lib/bushel_entry.mli
···127128val mention_entries : t -> Bushel_tags.t list -> entry list
129(** [mention_entries entries tags] returns entries mentioned in the tags. *)
00000000000000000
···127128val mention_entries : t -> Bushel_tags.t list -> entry list
129(** [mention_entries entries tags] returns entries mentioned in the tags. *)
130+131+(** {1 Thumbnail Functions} *)
132+133+val smallest_webp_variant : Srcsetter.t -> string
134+(** [smallest_webp_variant img] returns URL path to smallest webp variant above 480px. *)
135+136+val contact_thumbnail_slug : Sortal_schema.Contact.t -> string option
137+(** [contact_thumbnail_slug contact] returns the image slug for a contact. *)
138+139+val contact_thumbnail : t -> Sortal_schema.Contact.t -> string option
140+(** [contact_thumbnail entries contact] returns the thumbnail URL for a contact. *)
141+142+val thumbnail_slug : t -> entry -> string option
143+(** [thumbnail_slug entries entry] returns the image slug for an entry. *)
144+145+val thumbnail : t -> entry -> string option
146+(** [thumbnail entries entry] returns the thumbnail URL for an entry. *)
+263
ocaml-bushel/lib/bushel_md.ml
···17 - Plain HTML mode for feeds and simple output
18*)
1900000000000000000020(** {1 Link Detection} *)
2122let is_bushel_slug = String.starts_with ~prefix:":"
···103 | _ -> None)
104 | _ -> None
105000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000106(** {1 Link-Only Mapper}
107108 Converts Bushel links to regular HTML links without sidenotes.
···179 | None -> Mapper.default))
180 | None -> Mapper.default))
181 | _ -> Mapper.default
000182183(** {1 Slug Scanning} *)
184···623 let mapper = Mapper.make ~inline:(make_to_markdown_mapper ~base_url ~image_base entries) () in
624 let mapped_doc = Mapper.map_doc mapper doc in
625 Cmarkit_commonmark.of_doc mapped_doc
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
···17 - Plain HTML mode for feeds and simple output
18*)
1920+(** {1 Sidenote Types}
21+22+ Sidenote data types for interactive previews on hover.
23+ These are defined here as Cmarkit inline extensions that can be
24+ generated by the sidenote mapper and rendered by the webserver. *)
25+26+type sidenote_data =
27+ | Contact_note of Sortal_schema.Contact.t * string
28+ | Paper_note of Bushel_paper.t * string
29+ | Idea_note of Bushel_idea.t * string
30+ | Note_note of Bushel_note.t * string
31+ | Project_note of Bushel_project.t * string
32+ | Video_note of Bushel_video.t * string
33+ | Footnote_note of string * Cmarkit.Block.t * string
34+35+(** Extensible inline for sidenotes *)
36+type Cmarkit.Inline.t += Side_note of sidenote_data
37+38(** {1 Link Detection} *)
3940let is_bushel_slug = String.starts_with ~prefix:":"
···121 | _ -> None)
122 | _ -> None
123124+(** {1 Sidenote Mapper}
125+126+ Creates sidenotes for Bushel links. Used for interactive previews
127+ on the main website. *)
128+129+let make_sidenote_mapper entries =
130+ let open Cmarkit in
131+ fun _m ->
132+ function
133+ | Inline.Link (lb, meta) ->
134+ (match link_target_is_bushel lb with
135+ | Some (url, title) ->
136+ let s = strip_handle url in
137+ if is_tag_slug url then
138+ (* Tag link - keep as regular link with ## prefix for renderer *)
139+ let txt = Inline.Text (title, meta) in
140+ let ld = Link_definition.make ~dest:(url, meta) () in
141+ let ll = `Inline (ld, meta) in
142+ let link = Inline.Link.make txt ll in
143+ Mapper.ret (Inline.Link (link, meta))
144+ else if is_contact_slug url then
145+ (* Contact sidenote *)
146+ (match List.find_opt (fun c -> Sortal_schema.Contact.handle c = s) (Bushel_entry.contacts entries) with
147+ | Some c ->
148+ let sidenote = Side_note (Contact_note (c, title)) in
149+ Mapper.ret sidenote
150+ | None ->
151+ (* Contact not found, fallback to text *)
152+ let txt = Inline.Text (title, meta) in
153+ Mapper.ret txt)
154+ else
155+ (* Check entry type and generate appropriate sidenote *)
156+ (match Bushel_entry.lookup entries s with
157+ | Some (`Paper p) ->
158+ let sidenote = Side_note (Paper_note (p, title)) in
159+ Mapper.ret sidenote
160+ | Some (`Idea i) ->
161+ let sidenote = Side_note (Idea_note (i, title)) in
162+ Mapper.ret sidenote
163+ | Some (`Note n) ->
164+ let sidenote = Side_note (Note_note (n, title)) in
165+ Mapper.ret sidenote
166+ | Some (`Project p) ->
167+ let sidenote = Side_note (Project_note (p, title)) in
168+ Mapper.ret sidenote
169+ | Some (`Video v) ->
170+ let sidenote = Side_note (Video_note (v, title)) in
171+ Mapper.ret sidenote
172+ | None ->
173+ (* Entry not found, use regular link *)
174+ let dest = Bushel_entry.lookup_site_url entries s in
175+ let txt = Inline.Text (title, meta) in
176+ let ld = Link_definition.make ~dest:(dest, meta) () in
177+ let ll = `Inline (ld, meta) in
178+ let link = Inline.Link.make txt ll in
179+ Mapper.ret (Inline.Link (link, meta)))
180+ | None ->
181+ (* Handle reference-style links *)
182+ (match Inline.Link.referenced_label lb with
183+ | Some l ->
184+ let m = Label.meta l in
185+ (match Meta.find authorlink m with
186+ | Some () ->
187+ let slug = Label.key l in
188+ let s = strip_handle slug in
189+ (match List.find_opt (fun c -> Sortal_schema.Contact.handle c = s) (Bushel_entry.contacts entries) with
190+ | Some c ->
191+ let name = Sortal_schema.Contact.name c in
192+ let sidenote = Side_note (Contact_note (c, name)) in
193+ Mapper.ret sidenote
194+ | None ->
195+ let title = Inline.Link.text lb |> text_of_inline in
196+ let txt = Inline.Text (title, meta) in
197+ Mapper.ret txt)
198+ | None ->
199+ (match Meta.find sluglink m with
200+ | Some () ->
201+ let slug = Label.key l in
202+ if is_bushel_slug slug then
203+ let s = strip_handle slug in
204+ let title = Inline.Link.text lb |> text_of_inline in
205+ (match Bushel_entry.lookup entries s with
206+ | Some (`Paper p) -> Mapper.ret (Side_note (Paper_note (p, title)))
207+ | Some (`Idea i) -> Mapper.ret (Side_note (Idea_note (i, title)))
208+ | Some (`Note n) -> Mapper.ret (Side_note (Note_note (n, title)))
209+ | Some (`Project p) -> Mapper.ret (Side_note (Project_note (p, title)))
210+ | Some (`Video v) -> Mapper.ret (Side_note (Video_note (v, title)))
211+ | None ->
212+ let dest = Bushel_entry.lookup_site_url entries s in
213+ let txt = Inline.Text (title, meta) in
214+ let ld = Link_definition.make ~dest:(dest, meta) () in
215+ let ll = `Inline (ld, meta) in
216+ let link = Inline.Link.make txt ll in
217+ Mapper.ret (Inline.Link (link, meta)))
218+ else if is_tag_slug slug then
219+ let title = Inline.Link.text lb |> text_of_inline in
220+ let txt = Inline.Text (title, meta) in
221+ let ld = Link_definition.make ~dest:(slug, meta) () in
222+ let ll = `Inline (ld, meta) in
223+ let link = Inline.Link.make txt ll in
224+ Mapper.ret (Inline.Link (link, meta))
225+ else Mapper.default
226+ | None -> Mapper.default))
227+ | None -> Mapper.default))
228+ | Inline.Image (lb, meta) ->
229+ (* Handle images with bushel slugs *)
230+ (match image_target_is_bushel lb with
231+ | Some (url, alt, caption) ->
232+ let s = strip_handle url in
233+ (* Check if this is a video - if so, use /videos/ path *)
234+ (match Bushel_entry.lookup entries s with
235+ | Some (`Video _) ->
236+ let dest = Printf.sprintf "/videos/%s" s in
237+ let txt = Inline.Text (caption, meta) in
238+ let ld = Link_definition.make ?title:alt ~dest:(dest, meta) () in
239+ let ll = `Inline (ld, meta) in
240+ let img = Inline.Link.make txt ll in
241+ Mapper.ret (Inline.Image (img, meta))
242+ | _ ->
243+ (* Convert bushel slug to /images/ path *)
244+ let dest = Printf.sprintf "/images/%s.webp" s in
245+ let txt = Inline.Text (caption, meta) in
246+ let ld = Link_definition.make ?title:alt ~dest:(dest, meta) () in
247+ let ll = `Inline (ld, meta) in
248+ let img = Inline.Link.make txt ll in
249+ Mapper.ret (Inline.Image (img, meta)))
250+ | None -> Mapper.default)
251+ | _ -> Mapper.default
252+253+(** Alias for compatibility *)
254+let make_bushel_inline_mapper = make_sidenote_mapper
255+256(** {1 Link-Only Mapper}
257258 Converts Bushel links to regular HTML links without sidenotes.
···329 | None -> Mapper.default))
330 | None -> Mapper.default))
331 | _ -> Mapper.default
332+333+(** Alias for compatibility *)
334+let make_bushel_link_only_mapper _defs = make_link_only_mapper
335336(** {1 Slug Scanning} *)
337···776 let mapper = Mapper.make ~inline:(make_to_markdown_mapper ~base_url ~image_base entries) () in
777 let mapped_doc = Mapper.map_doc mapper doc in
778 Cmarkit_commonmark.of_doc mapped_doc
779+780+(** {1 References}
781+782+ Reference extraction for CiTO annotations. *)
783+784+(** Reference source type for CiTO annotations *)
785+type reference_source =
786+ | Paper (** CitesAsSourceDocument *)
787+ | Note (** CitesAsRelated *)
788+ | External (** Cites *)
789+790+(** Extract references (papers/notes with DOIs) from a note.
791+ Returns a list of (doi, citation_text, reference_source) tuples.
792+793+ @param entries The entry collection
794+ @param default_author The default author contact for notes without explicit author
795+ @param note The note to extract references from *)
796+let note_references entries (default_author:Sortal_schema.Contact.t) note =
797+ let refs = ref [] in
798+799+ (* Helper to format author name: extract last name from full name *)
800+ let format_author_last name =
801+ let parts = String.split_on_char ' ' name in
802+ List.nth parts (List.length parts - 1)
803+ in
804+805+ (* Helper to format a citation *)
806+ let format_citation ~authors ~year ~title ~publisher =
807+ let author_str = match authors with
808+ | [] -> ""
809+ | [author] -> format_author_last author ^ " "
810+ | author :: _ -> (format_author_last author) ^ " et al "
811+ in
812+ let pub_str = match publisher with
813+ | None | Some "" -> ""
814+ | Some p -> p ^ ". "
815+ in
816+ Printf.sprintf "%s(%d). %s. %s" author_str year title pub_str
817+ in
818+819+ (* Check slug_ent if it exists *)
820+ (match Bushel_note.slug_ent note with
821+ | Some slug ->
822+ (match Bushel_entry.lookup entries slug with
823+ | Some (`Paper p) ->
824+ (match Bushel_paper.doi p with
825+ | Some doi ->
826+ let authors = Bushel_paper.authors p in
827+ let year = Bushel_paper.year p in
828+ let title = Bushel_paper.title p in
829+ let publisher = Some (Bushel_paper.publisher p) in
830+ let citation = format_citation ~authors ~year ~title ~publisher in
831+ refs := (doi, citation, Paper) :: !refs
832+ | None -> ())
833+ | Some (`Note n) ->
834+ (match Bushel_note.doi n with
835+ | Some doi ->
836+ let authors = match Bushel_note.author n with
837+ | Some a -> [a]
838+ | None -> [Sortal_schema.Contact.name default_author]
839+ in
840+ let (year, _, _) = Bushel_note.date n in
841+ let title = Bushel_note.title n in
842+ let publisher = None in
843+ let citation = format_citation ~authors ~year ~title ~publisher in
844+ refs := (doi, citation, Note) :: !refs
845+ | None -> ())
846+ | _ -> ())
847+ | None -> ());
848+849+ (* Scan body for bushel references *)
850+ let slugs = scan_for_slugs entries (Bushel_note.body note) in
851+ List.iter (fun slug ->
852+ (* Strip leading : or @ from slug before lookup *)
853+ let normalized_slug = strip_handle slug in
854+ match Bushel_entry.lookup entries normalized_slug with
855+ | Some (`Paper p) ->
856+ (match Bushel_paper.doi p with
857+ | Some doi ->
858+ let authors = Bushel_paper.authors p in
859+ let year = Bushel_paper.year p in
860+ let title = Bushel_paper.title p in
861+ let publisher = Some (Bushel_paper.publisher p) in
862+ let citation = format_citation ~authors ~year ~title ~publisher in
863+ (* Check if doi already exists in refs *)
864+ if not (List.exists (fun (d, _, _) -> d = doi) !refs) then
865+ refs := (doi, citation, Paper) :: !refs
866+ | None -> ())
867+ | Some (`Note n) ->
868+ (match Bushel_note.doi n with
869+ | Some doi ->
870+ let authors = match Bushel_note.author n with
871+ | Some a -> [a]
872+ | None -> [Sortal_schema.Contact.name default_author]
873+ in
874+ let (year, _, _) = Bushel_note.date n in
875+ let title = Bushel_note.title n in
876+ let publisher = None in
877+ let citation = format_citation ~authors ~year ~title ~publisher in
878+ (* Check if doi already exists in refs *)
879+ if not (List.exists (fun (d, _, _) -> d = doi) !refs) then
880+ refs := (doi, citation, Note) :: !refs
881+ | None -> ())
882+ | _ -> ()
883+ ) slugs;
884+885+ (* TODO: Add external DOI URL scanning and publisher URL resolution *)
886+ (* This requires DOI caching infrastructure which is not yet ported *)
887+888+ List.rev !refs