···11-(** Attachments for JSON Feed items. *)
11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+module Unknown = struct
77+ type t = (string * Jsont.json) list
88+99+ let empty = []
1010+ let is_empty = function [] -> true | _ -> false
1111+end
212313type t = {
414 url : string;
···616 title : string option;
717 size_in_bytes : int64 option;
818 duration_in_seconds : int option;
1919+ unknown : Unknown.t;
920}
10212222+let make ~url ~mime_type ?title ?size_in_bytes ?duration_in_seconds ?(unknown = Unknown.empty) () =
2323+ { url; mime_type; title; size_in_bytes; duration_in_seconds; unknown }
2424+1125let create ~url ~mime_type ?title ?size_in_bytes ?duration_in_seconds () =
1212- { url; mime_type; title; size_in_bytes; duration_in_seconds }
2626+ make ~url ~mime_type ?title ?size_in_bytes ?duration_in_seconds ()
13271428let url t = t.url
1529let mime_type t = t.mime_type
1630let title t = t.title
1731let size_in_bytes t = t.size_in_bytes
1832let duration_in_seconds t = t.duration_in_seconds
3333+let unknown t = t.unknown
19342035let equal a b =
2136 a.url = b.url &&
···4964 | None -> ());
50655166 Format.fprintf ppf ")"
6767+6868+let jsont =
6969+ let kind = "Attachment" in
7070+ let doc = "An attachment object" in
7171+ let unknown_mems : (Unknown.t, Jsont.json, Jsont.mem list) Jsont.Object.Mems.map =
7272+ let open Jsont.Object.Mems in
7373+ let dec_empty () = [] in
7474+ let dec_add _meta (name : string) value acc =
7575+ ((name, Jsont.Meta.none), value) :: acc
7676+ in
7777+ let dec_finish _meta mems =
7878+ List.rev_map (fun ((name, _meta), value) -> (name, value)) mems in
7979+ let enc = {
8080+ enc = fun (type acc) (f : Jsont.Meta.t -> string -> Jsont.json -> acc -> acc) unknown (acc : acc) ->
8181+ List.fold_left (fun acc (name, value) ->
8282+8383+ f Jsont.Meta.none name value acc
8484+ ) acc unknown
8585+ } in
8686+ map ~kind:"Unknown members" Jsont.json ~dec_empty ~dec_add ~dec_finish ~enc
8787+ in
8888+ let make_obj url mime_type title size_in_bytes duration_in_seconds unknown =
8989+ make ~url ~mime_type ?title ?size_in_bytes ?duration_in_seconds ~unknown ()
9090+ in
9191+ Jsont.Object.map ~kind ~doc make_obj
9292+ |> Jsont.Object.mem "url" Jsont.string ~enc:url
9393+ |> Jsont.Object.mem "mime_type" Jsont.string ~enc:mime_type
9494+ |> Jsont.Object.opt_mem "title" Jsont.string ~enc:title
9595+ |> Jsont.Object.opt_mem "size_in_bytes" Jsont.int64 ~enc:size_in_bytes
9696+ |> Jsont.Object.opt_mem "duration_in_seconds" Jsont.int ~enc:duration_in_seconds
9797+ |> Jsont.Object.keep_unknown unknown_mems ~enc:unknown
9898+ |> Jsont.Object.finish
+51-16
lib/attachment.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16(** Attachments for JSON Feed items.
2738 An attachment represents an external resource related to a feed item,
···1116type t
121713181919+(** {1 Unknown Fields} *)
2020+2121+module Unknown : sig
2222+ type t = (string * Jsont.json) list
2323+ (** Unknown/unrecognized JSON object members.
2424+ Useful for preserving fields from custom extensions or future spec versions. *)
2525+2626+ val empty : t
2727+ (** [empty] is the empty list of unknown fields. *)
2828+2929+ val is_empty : t -> bool
3030+ (** [is_empty u] returns [true] if there are no unknown fields. *)
3131+end
3232+3333+3434+(** {1 Jsont Type} *)
3535+3636+val jsont : t Jsont.t
3737+(** Declarative JSON type for attachments.
3838+3939+ Maps JSON objects with "url" (required), "mime_type" (required),
4040+ and optional "title", "size_in_bytes", "duration_in_seconds" fields. *)
4141+4242+1443(** {1 Construction} *)
15444545+val create :
4646+ url:string ->
4747+ mime_type:string ->
4848+ ?title:string ->
4949+ ?size_in_bytes:int64 ->
5050+ ?duration_in_seconds:int ->
5151+ unit ->
5252+ t
1653(** [create ~url ~mime_type ?title ?size_in_bytes ?duration_in_seconds ()]
1754 creates an attachment object.
1855···3774 ~title:"Episode 42"
3875 ~size_in_bytes:15_728_640L
3976 ~duration_in_seconds:1800 ()
4040-4141- (* Alternate format (same title indicates same content) *)
4242- let att2 = Attachment.create
4343- ~url:"https://example.com/episode.aac"
4444- ~mime_type:"audio/aac"
4545- ~title:"Episode 42"
4646- ~size_in_bytes:12_582_912L
4747- ~duration_in_seconds:1800 ()
4877 ]} *)
4949-val create :
7878+7979+val make :
5080 url:string ->
5181 mime_type:string ->
5282 ?title:string ->
5383 ?size_in_bytes:int64 ->
5484 ?duration_in_seconds:int ->
8585+ ?unknown:Unknown.t ->
5586 unit ->
5687 t
8888+(** [make] is like {!create} but allows setting unknown fields. *)
578958905991(** {1 Accessors} *)
60929393+val url : t -> string
6194(** [url t] returns the attachment's URL. *)
6262-val url : t -> string
63956464-(** [mime_type t] returns the attachment's MIME type. *)
6596val mime_type : t -> string
9797+(** [mime_type t] returns the attachment's MIME type. *)
66986767-(** [title t] returns the attachment's title, if set. *)
6899val title : t -> string option
100100+(** [title t] returns the attachment's title, if set. *)
691017070-(** [size_in_bytes t] returns the attachment's size in bytes, if set. *)
71102val size_in_bytes : t -> int64 option
103103+(** [size_in_bytes t] returns the attachment's size in bytes, if set. *)
721047373-(** [duration_in_seconds t] returns the attachment's duration, if set. *)
74105val duration_in_seconds : t -> int option
106106+(** [duration_in_seconds t] returns the attachment's duration, if set. *)
107107+108108+val unknown : t -> Unknown.t
109109+(** [unknown t] returns unrecognized fields from the JSON. *)
751107611177112(** {1 Comparison} *)
781137979-(** [equal a b] tests equality between two attachments. *)
80114val equal : t -> t -> bool
115115+(** [equal a b] tests equality between two attachments. *)
811168211783118(** {1 Pretty Printing} *)
84119120120+val pp : Format.formatter -> t -> unit
85121(** [pp ppf t] pretty prints an attachment to the formatter.
8612287123 The output is human-readable and suitable for debugging.
8812489125 {b Example output:}
90126 {v episode.mp3 (audio/mpeg, 15.0 MB, 30m0s) v} *)
9191-val pp : Format.formatter -> t -> unit
+50-3
lib/author.ml
···11-(** Author information for JSON Feed items and feeds. *)
11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+module Unknown = struct
77+ type t = (string * Jsont.json) list
88+99+ let empty = []
1010+ let is_empty = function [] -> true | _ -> false
1111+end
212313type t = {
414 name : string option;
515 url : string option;
616 avatar : string option;
1717+ unknown : Unknown.t;
718}
8192020+let make ?name ?url ?avatar ?(unknown = Unknown.empty) () =
2121+ { name; url; avatar; unknown }
2222+923let create ?name ?url ?avatar () =
1024 if name = None && url = None && avatar = None then
1125 invalid_arg "Author.create: at least one field (name, url, or avatar) must be provided";
1212- { name; url; avatar }
2626+ make ?name ?url ?avatar ()
13271428let name t = t.name
1529let url t = t.url
1630let avatar t = t.avatar
3131+let unknown t = t.unknown
17321833let is_valid t =
1934 t.name <> None || t.url <> None || t.avatar <> None
20352136let equal a b =
2222- a.name = b.name && a.url = b.url && a.avatar = b.avatar
3737+ a.name = b.name &&
3838+ a.url = b.url &&
3939+ a.avatar = b.avatar
23402441let pp ppf t =
2542 match t.name, t.url with
···3047 match t.avatar with
3148 | Some avatar -> Format.fprintf ppf "(avatar: %s)" avatar
3249 | None -> Format.fprintf ppf "(empty author)"
5050+5151+let jsont =
5252+ let kind = "Author" in
5353+ let doc = "An author object with at least one field set" in
5454+ (* Custom mems map for Unknown.t that strips metadata from names *)
5555+ let unknown_mems : (Unknown.t, Jsont.json, Jsont.mem list) Jsont.Object.Mems.map =
5656+ let open Jsont.Object.Mems in
5757+ let dec_empty () = [] in
5858+ let dec_add _meta (name : string) value acc =
5959+ ((name, Jsont.Meta.none), value) :: acc
6060+ in
6161+ let dec_finish _meta mems =
6262+ List.rev_map (fun ((name, _meta), value) -> (name, value)) mems
6363+ in
6464+ let enc = {
6565+ enc = fun (type acc) (f : Jsont.Meta.t -> string -> Jsont.json -> acc -> acc) unknown (acc : acc) ->
6666+ List.fold_left (fun acc (name, value) ->
6767+ f Jsont.Meta.none name value acc
6868+ ) acc unknown
6969+ } in
7070+ map ~kind:"Unknown members" Jsont.json ~dec_empty ~dec_add ~dec_finish ~enc
7171+ in
7272+ (* Constructor that matches the jsont object map pattern *)
7373+ let make_obj name url avatar unknown = make ?name ?url ?avatar ~unknown () in
7474+ Jsont.Object.map ~kind ~doc make_obj
7575+ |> Jsont.Object.opt_mem "name" Jsont.string ~enc:name
7676+ |> Jsont.Object.opt_mem "url" Jsont.string ~enc:url
7777+ |> Jsont.Object.opt_mem "avatar" Jsont.string ~enc:avatar
7878+ |> Jsont.Object.keep_unknown unknown_mems ~enc:unknown
7979+ |> Jsont.Object.finish
+46-8
lib/author.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16(** Author information for JSON Feed items and feeds.
2738 An author object provides information about the creator of a feed or item.
···1116type t
121713181919+(** {1 Unknown Fields} *)
2020+2121+module Unknown : sig
2222+ type t = (string * Jsont.json) list
2323+ (** Unknown/unrecognized JSON object members.
2424+ Useful for preserving fields from custom extensions or future spec versions. *)
2525+2626+ val empty : t
2727+ (** [empty] is the empty list of unknown fields. *)
2828+2929+ val is_empty : t -> bool
3030+ (** [is_empty u] returns [true] if there are no unknown fields. *)
3131+end
3232+3333+3434+(** {1 Jsont Type} *)
3535+3636+val jsont : t Jsont.t
3737+(** Declarative JSON type for authors.
3838+3939+ Maps JSON objects with optional "name", "url", and "avatar" fields.
4040+ At least one field must be present during decoding. *)
4141+4242+1443(** {1 Construction} *)
15441616-(** [create ?name ?url ?avatar ()] creates an author object.
4545+val create : ?name:string -> ?url:string -> ?avatar:string -> unit -> t
4646+(** [create ?name ?url ?avatar ()] creates an author.
17471848 At least one of the optional parameters must be provided, otherwise
1949 the function will raise [Invalid_argument].
···3161 ~url:"https://janedoe.com"
3262 ~avatar:"https://janedoe.com/avatar.png" ()
3363 ]} *)
3434-val create : ?name:string -> ?url:string -> ?avatar:string -> unit -> t
6464+6565+val make :
6666+ ?name:string -> ?url:string -> ?avatar:string ->
6767+ ?unknown:Unknown.t -> unit -> t
6868+(** [make] is like {!create} but allows setting unknown fields.
6969+ Useful when round-tripping JSON with custom extensions. *)
357036713772(** {1 Accessors} *)
38737474+val name : t -> string option
3975(** [name t] returns the author's name, if set. *)
4040-val name : t -> string option
41764242-(** [url t] returns the author's URL, if set. *)
4377val url : t -> string option
7878+(** [url t] returns the author's URL, if set. *)
44798080+val avatar : t -> string option
4581(** [avatar t] returns the author's avatar URL, if set. *)
4646-val avatar : t -> string option
8282+8383+val unknown : t -> Unknown.t
8484+(** [unknown t] returns unrecognized fields from the JSON. *)
478548864987(** {1 Predicates} *)
50888989+val is_valid : t -> bool
5190(** [is_valid t] checks if the author has at least one field set.
52915392 This should always return [true] for authors created via {!create},
5493 but may be useful when parsing from external sources. *)
5555-val is_valid : t -> bool
569457955896(** {1 Comparison} *)
59976060-(** [equal a b] tests equality between two authors. *)
6198val equal : t -> t -> bool
9999+(** [equal a b] tests equality between two authors. *)
621006310164102(** {1 Pretty Printing} *)
65103104104+val pp : Format.formatter -> t -> unit
66105(** [pp ppf t] pretty prints an author to the formatter.
6710668107 The output is human-readable and suitable for debugging.
6910870109 {b Example output:}
71110 {v Jane Doe <https://janedoe.com> v} *)
7272-val pp : Format.formatter -> t -> unit
+7
lib/cito.ml
···159159 | _ -> a = b
160160161161let pp ppf t = Format.fprintf ppf "%s" (to_string t)
162162+163163+let jsont =
164164+ let kind = "CiTO intent" in
165165+ let doc = "A Citation Typing Ontology intent annotation" in
166166+ let dec = of_string in
167167+ let enc = to_string in
168168+ Jsont.map ~kind ~doc ~dec ~enc Jsont.string
+9
lib/cito.mli
···142142val equal : t -> t -> bool
143143144144145145+(** {1 Jsont Type} *)
146146+147147+val jsont : t Jsont.t
148148+(** Declarative JSON type for CiTO annotations.
149149+150150+ Maps CiTO intent strings to the corresponding variants.
151151+ Unknown intents are mapped to [`Other s]. *)
152152+153153+145154(** {1 Pretty Printing} *)
146155147156(** [pp ppf t] pretty prints a CiTO annotation to the formatter.
···11-(** Hub endpoints for real-time notifications. *)
11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+module Unknown = struct
77+ type t = (string * Jsont.json) list
88+99+ let empty = []
1010+ let is_empty = function [] -> true | _ -> false
1111+end
212313type t = {
414 type_ : string;
515 url : string;
1616+ unknown : Unknown.t;
617}
7181919+let make ~type_ ~url ?(unknown = Unknown.empty) () =
2020+ { type_; url; unknown }
2121+822let create ~type_ ~url () =
99- { type_; url }
2323+ make ~type_ ~url ()
10241125let type_ t = t.type_
1226let url t = t.url
2727+let unknown t = t.unknown
13281429let equal a b =
1530 a.type_ = b.type_ && a.url = b.url
16311732let pp ppf t =
1833 Format.fprintf ppf "%s: %s" t.type_ t.url
3434+3535+let jsont =
3636+ let kind = "Hub" in
3737+ let doc = "A hub endpoint" in
3838+ let unknown_mems : (Unknown.t, Jsont.json, Jsont.mem list) Jsont.Object.Mems.map =
3939+ let open Jsont.Object.Mems in
4040+ let dec_empty () = [] in
4141+ let dec_add _meta (name : string) value acc =
4242+ ((name, Jsont.Meta.none), value) :: acc
4343+ in
4444+ let dec_finish _meta mems =
4545+ List.rev_map (fun ((name, _meta), value) -> (name, value)) mems in
4646+ let enc = {
4747+ enc = fun (type acc) (f : Jsont.Meta.t -> string -> Jsont.json -> acc -> acc) unknown (acc : acc) ->
4848+ List.fold_left (fun acc (name, value) ->
4949+ f Jsont.Meta.none name value acc
5050+ ) acc unknown
5151+ } in
5252+ map ~kind:"Unknown members" Jsont.json ~dec_empty ~dec_add ~dec_finish ~enc
5353+ in
5454+ let make_obj type_ url unknown = make ~type_ ~url ~unknown () in
5555+ Jsont.Object.map ~kind ~doc make_obj
5656+ |> Jsont.Object.mem "type" Jsont.string ~enc:type_
5757+ |> Jsont.Object.mem "url" Jsont.string ~enc:url
5858+ |> Jsont.Object.keep_unknown unknown_mems ~enc:unknown
5959+ |> Jsont.Object.finish
+41-5
lib/hub.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16(** Hub endpoints for real-time notifications.
2738 Hubs describe endpoints that can be used to subscribe to real-time
···1116type t
121713181919+(** {1 Unknown Fields} *)
2020+2121+module Unknown : sig
2222+ type t = (string * Jsont.json) list
2323+ (** Unknown/unrecognized JSON object members.
2424+ Useful for preserving fields from custom extensions or future spec versions. *)
2525+2626+ val empty : t
2727+ (** [empty] is the empty list of unknown fields. *)
2828+2929+ val is_empty : t -> bool
3030+ (** [is_empty u] returns [true] if there are no unknown fields. *)
3131+end
3232+3333+3434+(** {1 Jsont Type} *)
3535+3636+val jsont : t Jsont.t
3737+(** Declarative JSON type for hubs.
3838+3939+ Maps JSON objects with "type" and "url" fields (both required). *)
4040+4141+1442(** {1 Construction} *)
15434444+val create : type_:string -> url:string -> unit -> t
1645(** [create ~type_ ~url ()] creates a hub object.
17461847 @param type_ The type of hub protocol (e.g., ["rssCloud"], ["WebSub"])
···2453 ~type_:"WebSub"
2554 ~url:"https://pubsubhubbub.appspot.com/" ()
2655 ]} *)
2727-val create : type_:string -> url:string -> unit -> t
5656+5757+val make :
5858+ type_:string -> url:string ->
5959+ ?unknown:Unknown.t -> unit -> t
6060+(** [make] is like {!create} but allows setting unknown fields. *)
286129623063(** {1 Accessors} *)
31643232-(** [type_ t] returns the hub's protocol type. *)
3365val type_ : t -> string
6666+(** [type_ t] returns the hub's protocol type. *)
34676868+val url : t -> string
3569(** [url t] returns the hub's endpoint URL. *)
3636-val url : t -> string
7070+7171+val unknown : t -> Unknown.t
7272+(** [unknown t] returns unrecognized fields from the JSON. *)
377338743975(** {1 Comparison} *)
40764141-(** [equal a b] tests equality between two hubs. *)
4277val equal : t -> t -> bool
7878+(** [equal a b] tests equality between two hubs. *)
437944804581(** {1 Pretty Printing} *)
46828383+val pp : Format.formatter -> t -> unit
4784(** [pp ppf t] pretty prints a hub to the formatter.
48854986 The output is human-readable and suitable for debugging.
50875188 {b Example output:}
5289 {v WebSub: https://pubsubhubbub.appspot.com/ v} *)
5353-val pp : Format.formatter -> t -> unit
+116-36
lib/item.ml
···11-(** Feed items in a JSON Feed. *)
11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+module Unknown = struct
77+ type t = (string * Jsont.json) list
88+99+ let empty = []
1010+ let is_empty = function [] -> true | _ -> false
1111+end
21233-type content =
44- [ `Html of string
1313+type content = [
1414+ | `Html of string
515 | `Text of string
616 | `Both of string * string
77- ]
1717+]
818919type t = {
1020 id : string;
···2232 language : string option;
2333 attachments : Attachment.t list option;
2434 references : Reference.t list option;
3535+ unknown : Unknown.t;
2536}
26372727-let create ~id ~content ?url ?external_url ?title ?summary ?image ?banner_image
2828- ?date_published ?date_modified ?authors ?tags ?language ?attachments ?references () =
3838+let make ~id ~content ?url ?external_url ?title ?summary ?image ?banner_image
3939+ ?date_published ?date_modified ?authors ?tags ?language ?attachments ?references
4040+ ?(unknown = Unknown.empty) () =
2941 {
3030- id;
3131- content;
3232- url;
3333- external_url;
3434- title;
3535- summary;
3636- image;
3737- banner_image;
3838- date_published;
3939- date_modified;
4040- authors;
4141- tags;
4242- language;
4343- attachments;
4444- references;
4242+ id; content; url; external_url; title; summary; image; banner_image;
4343+ date_published; date_modified; authors; tags; language; attachments; references;
4444+ unknown;
4545 }
4646+4747+let create ~id ~content ?url ?external_url ?title ?summary ?image ?banner_image
4848+ ?date_published ?date_modified ?authors ?tags ?language ?attachments ?references () =
4949+ make ~id ~content ?url ?external_url ?title ?summary ?image ?banner_image
5050+ ?date_published ?date_modified ?authors ?tags ?language ?attachments ?references ()
46514752let id t = t.id
4853let content t = t.content
···5964let language t = t.language
6065let attachments t = t.attachments
6166let references t = t.references
6767+let unknown t = t.unknown
62686369let content_html t =
6470 match t.content with
···7278 | `Text text -> Some text
7379 | `Both (_, text) -> Some text
74807575-let equal a b =
7676- (* Items are equal if they have the same ID *)
7777- a.id = b.id
8181+let equal a b = a.id = b.id
78827983let compare a b =
8080- (* Compare by publication date, with items without dates considered older *)
8184 match a.date_published, b.date_published with
8285 | None, None -> 0
8383- | None, Some _ -> -1 (* Items without dates are "older" *)
8686+ | None, Some _ -> -1
8487 | Some _, None -> 1
8588 | Some da, Some db -> Ptime.compare da db
86898787-let pp_content ppf = function
8888- | `Html html ->
8989- Format.fprintf ppf "HTML (%d chars)" (String.length html)
9090- | `Text text ->
9191- Format.fprintf ppf "Text (%d chars)" (String.length text)
9292- | `Both (html, text) ->
9393- Format.fprintf ppf "Both (HTML: %d chars, Text: %d chars)"
9494- (String.length html) (String.length text)
9595-9690let pp ppf t =
9791 match t.date_published, t.title with
9892 | Some date, Some title ->
9999- (* Use Ptime's date formatting *)
10093 let (y, m, d), _ = Ptime.to_date_time date in
10194 Format.fprintf ppf "[%04d-%02d-%02d] %s (%s)" y m d title t.id
10295 | Some date, None ->
···10699 Format.fprintf ppf "%s (%s)" title t.id
107100 | None, None ->
108101 Format.fprintf ppf "%s" t.id
102102+103103+let pp_summary ppf t =
104104+ match t.title with
105105+ | Some title -> Format.fprintf ppf "%s" title
106106+ | None -> Format.fprintf ppf "%s" t.id
107107+108108+(* Jsont type *)
109109+110110+let jsont =
111111+ let kind = "Item" in
112112+ let doc = "A JSON Feed item" in
113113+114114+ (* Helper to construct item from JSON fields *)
115115+ let make_from_json id content_html content_text url external_url title summary
116116+ image banner_image date_published date_modified authors tags language
117117+ attachments references _extensions unknown =
118118+ (* Determine content from content_html and content_text *)
119119+ let content = match content_html, content_text with
120120+ | Some html, Some text -> `Both (html, text)
121121+ | Some html, None -> `Html html
122122+ | None, Some text -> `Text text
123123+ | None, None ->
124124+ Jsont.Error.msg Jsont.Meta.none
125125+ "Item must have at least one of content_html or content_text"
126126+ in
127127+ { id; content; url; external_url; title; summary; image; banner_image;
128128+ date_published; date_modified; authors; tags; language; attachments;
129129+ references; unknown }
130130+ in
131131+132132+ (* Encoders to extract fields from item *)
133133+ let enc_id t = t.id in
134134+ let enc_content_html t = content_html t in
135135+ let enc_content_text t = content_text t in
136136+ let enc_url t = t.url in
137137+ let enc_external_url t = t.external_url in
138138+ let enc_title t = t.title in
139139+ let enc_summary t = t.summary in
140140+ let enc_image t = t.image in
141141+ let enc_banner_image t = t.banner_image in
142142+ let enc_date_published t = t.date_published in
143143+ let enc_date_modified t = t.date_modified in
144144+ let enc_authors t = t.authors in
145145+ let enc_tags t = t.tags in
146146+ let enc_language t = t.language in
147147+ let enc_attachments t = t.attachments in
148148+ let enc_references t = t.references in
149149+ let enc_unknown t = t.unknown in
150150+151151+ let unknown_mems : (Unknown.t, Jsont.json, Jsont.mem list) Jsont.Object.Mems.map =
152152+ let open Jsont.Object.Mems in
153153+ let dec_empty () = [] in
154154+ let dec_add _meta (name : string) value acc =
155155+ ((name, Jsont.Meta.none), value) :: acc
156156+ in
157157+ let dec_finish _meta mems =
158158+ List.rev_map (fun ((name, _meta), value) -> (name, value)) mems in
159159+ let enc = {
160160+ enc = fun (type acc) (f : Jsont.Meta.t -> string -> Jsont.json -> acc -> acc) unknown (acc : acc) ->
161161+ List.fold_left (fun acc (name, value) ->
162162+163163+ f Jsont.Meta.none name value acc
164164+ ) acc unknown
165165+ } in
166166+ map ~kind:"Unknown members" Jsont.json ~dec_empty ~dec_add ~dec_finish ~enc
167167+ in
168168+169169+ Jsont.Object.map ~kind ~doc make_from_json
170170+ |> Jsont.Object.mem "id" Jsont.string ~enc:enc_id
171171+ |> Jsont.Object.opt_mem "content_html" Jsont.string ~enc:enc_content_html
172172+ |> Jsont.Object.opt_mem "content_text" Jsont.string ~enc:enc_content_text
173173+ |> Jsont.Object.opt_mem "url" Jsont.string ~enc:enc_url
174174+ |> Jsont.Object.opt_mem "external_url" Jsont.string ~enc:enc_external_url
175175+ |> Jsont.Object.opt_mem "title" Jsont.string ~enc:enc_title
176176+ |> Jsont.Object.opt_mem "summary" Jsont.string ~enc:enc_summary
177177+ |> Jsont.Object.opt_mem "image" Jsont.string ~enc:enc_image
178178+ |> Jsont.Object.opt_mem "banner_image" Jsont.string ~enc:enc_banner_image
179179+ |> Jsont.Object.opt_mem "date_published" Rfc3339.jsont ~enc:enc_date_published
180180+ |> Jsont.Object.opt_mem "date_modified" Rfc3339.jsont ~enc:enc_date_modified
181181+ |> Jsont.Object.opt_mem "authors" (Jsont.list Author.jsont) ~enc:enc_authors
182182+ |> Jsont.Object.opt_mem "tags" (Jsont.list Jsont.string) ~enc:enc_tags
183183+ |> Jsont.Object.opt_mem "language" Jsont.string ~enc:enc_language
184184+ |> Jsont.Object.opt_mem "attachments" (Jsont.list Attachment.jsont) ~enc:enc_attachments
185185+ |> Jsont.Object.opt_mem "_references" (Jsont.list Reference.jsont) ~enc:enc_references
186186+ |> Jsont.Object.opt_mem "_extensions" Jsont.json_object ~enc:(fun _t -> None)
187187+ |> Jsont.Object.keep_unknown unknown_mems ~enc:enc_unknown
188188+ |> Jsont.Object.finish
+51-129
lib/item.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16(** Feed items in a JSON Feed.
2738 An item represents a single entry in a feed, such as a blog post, podcast episode,
···1722 - [`Html s]: Item has HTML content only
1823 - [`Text s]: Item has plain text content only
1924 - [`Both (html, text)]: Item has both HTML and plain text versions *)
2020-type content =
2121- [ `Html of string
2525+type content = [
2626+ | `Html of string
2227 | `Text of string
2328 | `Both of string * string
2424- ]
2929+]
253026312727-(** {1 Construction} *)
3232+(** {1 Unknown Fields} *)
28332929-(** [create ~id ~content ?url ?external_url ?title ?summary ?image ?banner_image
3030- ?date_published ?date_modified ?authors ?tags ?language ?attachments ()]
3131- creates a feed item.
3434+module Unknown : sig
3535+ type t = (string * Jsont.json) list
3636+ (** Unknown/unrecognized JSON object members.
3737+ Useful for preserving fields from custom extensions or future spec versions. *)
32383333- @param id Unique identifier for the item (required). Should be a full URL if possible.
3434- @param content The item's content in HTML and/or plain text (required)
3535- @param url Permalink to the item
3636- @param external_url URL of an external resource (useful for linkblogs)
3737- @param title Plain text title of the item
3838- @param summary Plain text summary/excerpt of the item
3939- @param image URL of the main featured image for the item
4040- @param banner_image URL of a banner image for the item
4141- @param date_published Publication date/time (RFC 3339 format)
4242- @param date_modified Last modification date/time (RFC 3339 format)
4343- @param authors Item-specific authors (overrides feed-level authors)
4444- @param tags Plain text tags/categories for the item
4545- @param language Primary language of the item (RFC 5646 format, e.g. ["en-US"])
4646- @param attachments Related resources like audio files or downloads
4747- @param references References to cited sources (extension)
3939+ val empty : t
4040+ (** [empty] is the empty list of unknown fields. *)
48414949- {b Examples:}
5050- {[
5151- (* Simple blog post *)
5252- let item = Item.create
5353- ~id:"https://example.com/posts/42"
5454- ~content:(`Html "<p>Hello, world!</p>")
5555- ~title:"My First Post"
5656- ~url:"https://example.com/posts/42" ()
4242+ val is_empty : t -> bool
4343+ (** [is_empty u] returns [true] if there are no unknown fields. *)
4444+end
57455858- (* Microblog entry with plain text *)
5959- let item = Item.create
6060- ~id:"https://example.com/micro/123"
6161- ~content:(`Text "Just posted a new photo!")
6262- ~date_published:(Ptime.of_float_s (Unix.time ()) |> Option.get) ()
4646+4747+(** {1 Jsont Type} *)
4848+4949+val jsont : t Jsont.t
5050+(** Declarative JSON type for feed items.
5151+5252+ Maps JSON objects with "id" (required), content fields, and various optional metadata.
5353+ The content must have at least one of "content_html" or "content_text". *)
63546464- (* Article with both HTML and plain text *)
6565- let item = Item.create
6666- ~id:"https://example.com/article/99"
6767- ~content:(`Both ("<p>Rich content</p>", "Plain version"))
6868- ~title:"Article Title"
6969- ~tags:["ocaml"; "programming"] ()
70557171- (* Podcast episode with attachment *)
7272- let attachment = Attachment.create
7373- ~url:"https://example.com/ep1.mp3"
7474- ~mime_type:"audio/mpeg"
7575- ~duration_in_seconds:1800 () in
7676- let item = Item.create
7777- ~id:"https://example.com/podcast/1"
7878- ~content:(`Html "<p>Episode description</p>")
7979- ~title:"Episode 1"
8080- ~attachments:[attachment] ()
5656+(** {1 Construction} *)
81578282- (* Article with references *)
8383- let reference = Reference.create
8484- ~url:"https://doi.org/10.5281/zenodo.16755947"
8585- ~doi:"10.5281/zenodo.16755947"
8686- ~cito:[`CitesAsRecommendedReading; `UsesMethodIn] () in
8787- let item = Item.create
8888- ~id:"https://doi.org/10.59350/krw9n-dv417"
8989- ~content:(`Html "<p>Research article content</p>")
9090- ~title:"One Million IUPAC names #4: a lot is happening"
9191- ~url:"https://chem-bla-ics.linkedchemistry.info/2025/08/09/one-million-iupac-names-4.html"
9292- ~references:[reference] ()
9393- ]} *)
9458val create :
9559 id:string ->
9660 content:content ->
···11074 unit ->
11175 t
112767777+val make :
7878+ id:string ->
7979+ content:content ->
8080+ ?url:string ->
8181+ ?external_url:string ->
8282+ ?title:string ->
8383+ ?summary:string ->
8484+ ?image:string ->
8585+ ?banner_image:string ->
8686+ ?date_published:Ptime.t ->
8787+ ?date_modified:Ptime.t ->
8888+ ?authors:Author.t list ->
8989+ ?tags:string list ->
9090+ ?language:string ->
9191+ ?attachments:Attachment.t list ->
9292+ ?references:Reference.t list ->
9393+ ?unknown:Unknown.t ->
9494+ unit ->
9595+ t
9696+1139711498(** {1 Accessors} *)
11599116116-(** [id t] returns the item's unique identifier. *)
117100val id : t -> string
118118-119119-(** [content t] returns the item's content. *)
120101val content : t -> content
121121-122122-(** [url t] returns the item's permalink URL, if set. *)
102102+val content_html : t -> string option
103103+val content_text : t -> string option
123104val url : t -> string option
124124-125125-(** [external_url t] returns the external resource URL, if set. *)
126105val external_url : t -> string option
127127-128128-(** [title t] returns the item's title, if set. *)
129106val title : t -> string option
130130-131131-(** [summary t] returns the item's summary, if set. *)
132107val summary : t -> string option
133133-134134-(** [image t] returns the item's featured image URL, if set. *)
135108val image : t -> string option
136136-137137-(** [banner_image t] returns the item's banner image URL, if set. *)
138109val banner_image : t -> string option
139139-140140-(** [date_published t] returns the item's publication date, if set. *)
141110val date_published : t -> Ptime.t option
142142-143143-(** [date_modified t] returns the item's last modification date, if set. *)
144111val date_modified : t -> Ptime.t option
145145-146146-(** [authors t] returns the item's authors, if set. *)
147112val authors : t -> Author.t list option
148148-149149-(** [tags t] returns the item's tags, if set. *)
150113val tags : t -> string list option
151151-152152-(** [language t] returns the item's language code, if set. *)
153114val language : t -> string option
154154-155155-(** [attachments t] returns the item's attachments, if set. *)
156115val attachments : t -> Attachment.t list option
157157-158158-(** [references t] returns the item's references, if set. *)
159116val references : t -> Reference.t list option
160160-161161-162162-(** {1 Content Helpers} *)
163163-164164-(** [content_html t] extracts HTML content from the item.
165165-166166- Returns [Some html] if the item has HTML content (either [Html] or [Both]),
167167- [None] otherwise. *)
168168-val content_html : t -> string option
169169-170170-(** [content_text t] extracts plain text content from the item.
171171-172172- Returns [Some text] if the item has plain text content (either [Text] or [Both]),
173173- [None] otherwise. *)
174174-val content_text : t -> string option
117117+val unknown : t -> Unknown.t
175118176119177120(** {1 Comparison} *)
178121179179-(** [equal a b] tests equality between two items.
180180-181181- Items are considered equal if they have the same ID. *)
182122val equal : t -> t -> bool
183183-184184-(** [compare a b] compares two items by their publication dates.
185185-186186- Items without publication dates are considered older than items with dates.
187187- Useful for sorting items chronologically. *)
188123val compare : t -> t -> int
189124190125191126(** {1 Pretty Printing} *)
192127193193-(** [pp ppf t] pretty prints an item to the formatter.
194194-195195- The output is human-readable and suitable for debugging.
196196-197197- {b Example output:}
198198- {v [2024-11-03] My First Post (https://example.com/posts/42) v} *)
199128val pp : Format.formatter -> t -> unit
200200-201201-(** [pp_content ppf content] pretty prints content to the formatter.
202202-203203- {b Example output:}
204204- {v HTML (123 chars) v}
205205- {v Text (56 chars) v}
206206- {v Both (HTML: 123 chars, Text: 56 chars) v} *)
207207-val pp_content : Format.formatter -> content -> unit
129129+val pp_summary : Format.formatter -> t -> unit
+104-490
lib/jsonfeed.ml
···11-(** JSON Feed format parser and serializer. *)
22-33-exception Invalid_feed of string
11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
4566+module Rfc3339 = Rfc3339
77+module Cito = Cito
58module Author = Author
69module Attachment = Attachment
710module Hub = Hub
1111+module Reference = Reference
812module Item = Item
99-module Reference = Reference
1010-module Cito = Cito
1313+1414+module Unknown = struct
1515+ type t = (string * Jsont.json) list
1616+1717+ let empty = []
1818+ let is_empty = function [] -> true | _ -> false
1919+end
11201221type t = {
1322 version : string;
···2433 expired : bool option;
2534 hubs : Hub.t list option;
2635 items : Item.t list;
3636+ unknown : Unknown.t;
2737}
28382929-let create ~title ?home_page_url ?feed_url ?description ?user_comment
3030- ?next_url ?icon ?favicon ?authors ?language ?expired ?hubs ~items () =
3939+let make ~title ?home_page_url ?feed_url ?description ?user_comment
4040+ ?next_url ?icon ?favicon ?authors ?language ?expired ?hubs ~items
4141+ ?(unknown = Unknown.empty) () =
3142 {
3243 version = "https://jsonfeed.org/version/1.1";
3344 title;
···4354 expired;
4455 hubs;
4556 items;
5757+ unknown;
4658 }
47596060+let create ~title ?home_page_url ?feed_url ?description ?user_comment
6161+ ?next_url ?icon ?favicon ?authors ?language ?expired ?hubs ~items () =
6262+ make ~title ?home_page_url ?feed_url ?description ?user_comment
6363+ ?next_url ?icon ?favicon ?authors ?language ?expired ?hubs ~items ()
6464+4865let version t = t.version
4966let title t = t.title
5067let home_page_url t = t.home_page_url
···5976let expired t = t.expired
6077let hubs t = t.hubs
6178let items t = t.items
6262-6363-(* RFC3339 date utilities *)
6464-6565-let parse_rfc3339 s =
6666- match Ptime.of_rfc3339 s with
6767- | Ok (t, _, _) -> Some t
6868- | Error _ -> None
6969-7070-let format_rfc3339 t =
7171- Ptime.to_rfc3339 t
7272-7373-(* JSON parsing and serialization *)
7474-7575-type error = string
7676-7777-let error_msgf fmt = Format.kasprintf (fun s -> Error s) fmt
7878-7979-(* JSON parsing helpers *)
8080-8181-type json_value =
8282- | Null
8383- | Bool of bool
8484- | Float of float
8585- | String of string
8686- | Array of json_value list
8787- | Object of (string * json_value) list
8888-8989-let rec decode_value dec =
9090- match Jsonm.decode dec with
9191- | `Lexeme `Null -> Null
9292- | `Lexeme (`Bool b) -> Bool b
9393- | `Lexeme (`Float f) -> Float f
9494- | `Lexeme (`String s) -> String s
9595- | `Lexeme `Os -> decode_object dec []
9696- | `Lexeme `As -> decode_array dec []
9797- | `Lexeme _ -> Null
9898- | `Error err -> raise (Invalid_feed (Format.asprintf "%a" Jsonm.pp_error err))
9999- | `End | `Await -> Null
100100-101101-and decode_object dec acc =
102102- match Jsonm.decode dec with
103103- | `Lexeme `Oe -> Object (List.rev acc)
104104- | `Lexeme (`Name n) ->
105105- let v = decode_value dec in
106106- decode_object dec ((n, v) :: acc)
107107- | `Error err -> raise (Invalid_feed (Format.asprintf "%a" Jsonm.pp_error err))
108108- | _ -> Object (List.rev acc)
109109-110110-and decode_array dec acc =
111111- match Jsonm.decode dec with
112112- | `Lexeme `Ae -> Array (List.rev acc)
113113- | `Lexeme `Os ->
114114- let v = decode_object dec [] in
115115- decode_array dec (v :: acc)
116116- | `Lexeme `As ->
117117- let v = decode_array dec [] in
118118- decode_array dec (v :: acc)
119119- | `Lexeme `Null -> decode_array dec (Null :: acc)
120120- | `Lexeme (`Bool b) -> decode_array dec (Bool b :: acc)
121121- | `Lexeme (`Float f) -> decode_array dec (Float f :: acc)
122122- | `Lexeme (`String s) -> decode_array dec (String s :: acc)
123123- | `Error err -> raise (Invalid_feed (Format.asprintf "%a" Jsonm.pp_error err))
124124- | _ -> Array (List.rev acc)
125125-126126-(* Helpers to extract values from JSON *)
127127-128128-let get_string = function String s -> Some s | _ -> None
129129-let get_bool = function Bool b -> Some b | _ -> None
130130-let _get_float = function Float f -> Some f | _ -> None
131131-let get_int = function Float f -> Some (int_of_float f) | _ -> None
132132-let get_int64 = function Float f -> Some (Int64.of_float f) | _ -> None
133133-let get_array = function Array arr -> Some arr | _ -> None
134134-let _get_object = function Object obj -> Some obj | _ -> None
135135-136136-let find_field name obj = List.assoc_opt name obj
137137-138138-let require_field name obj =
139139- match find_field name obj with
140140- | Some v -> v
141141- | None -> raise (Invalid_feed (Printf.sprintf "Missing required field: %s" name))
142142-143143-let require_string name obj =
144144- match require_field name obj |> get_string with
145145- | Some s -> s
146146- | None -> raise (Invalid_feed (Printf.sprintf "Field %s must be a string" name))
147147-148148-let optional_string name obj =
149149- match find_field name obj with Some v -> get_string v | None -> None
150150-151151-let optional_bool name obj =
152152- match find_field name obj with Some v -> get_bool v | None -> None
153153-154154-let optional_int name obj =
155155- match find_field name obj with Some v -> get_int v | None -> None
156156-157157-let optional_int64 name obj =
158158- match find_field name obj with Some v -> get_int64 v | None -> None
159159-160160-let optional_array name obj =
161161- match find_field name obj with Some v -> get_array v | None -> None
162162-163163-(* Parse Author *)
164164-165165-let parse_author_obj obj =
166166- let name = optional_string "name" obj in
167167- let url = optional_string "url" obj in
168168- let avatar = optional_string "avatar" obj in
169169- if name = None && url = None && avatar = None then
170170- raise (Invalid_feed "Author must have at least one field");
171171- Author.create ?name ?url ?avatar ()
172172-173173-let parse_author = function
174174- | Object obj -> parse_author_obj obj
175175- | _ -> raise (Invalid_feed "Author must be an object")
176176-177177-(* Parse Attachment *)
178178-179179-let parse_attachment_obj obj =
180180- let url = require_string "url" obj in
181181- let mime_type = require_string "mime_type" obj in
182182- let title = optional_string "title" obj in
183183- let size_in_bytes = optional_int64 "size_in_bytes" obj in
184184- let duration_in_seconds = optional_int "duration_in_seconds" obj in
185185- Attachment.create ~url ~mime_type ?title ?size_in_bytes ?duration_in_seconds ()
186186-187187-let parse_attachment = function
188188- | Object obj -> parse_attachment_obj obj
189189- | _ -> raise (Invalid_feed "Attachment must be an object")
190190-191191-(* Parse Hub *)
192192-193193-let parse_hub_obj obj =
194194- let type_ = require_string "type" obj in
195195- let url = require_string "url" obj in
196196- Hub.create ~type_ ~url ()
197197-198198-let parse_hub = function
199199- | Object obj -> parse_hub_obj obj
200200- | _ -> raise (Invalid_feed "Hub must be an object")
201201-202202-(* Parse Item *)
203203-204204-let parse_item_obj obj =
205205- let id = require_string "id" obj in
206206-207207- (* Parse content - at least one required *)
208208- let content_html = optional_string "content_html" obj in
209209- let content_text = optional_string "content_text" obj in
210210- let content = match content_html, content_text with
211211- | Some html, Some text -> `Both (html, text)
212212- | Some html, None -> `Html html
213213- | None, Some text -> `Text text
214214- | None, None ->
215215- raise (Invalid_feed "Item must have content_html or content_text")
216216- in
217217-218218- let url = optional_string "url" obj in
219219- let external_url = optional_string "external_url" obj in
220220- let title = optional_string "title" obj in
221221- let summary = optional_string "summary" obj in
222222- let image = optional_string "image" obj in
223223- let banner_image = optional_string "banner_image" obj in
224224-225225- let date_published =
226226- match optional_string "date_published" obj with
227227- | Some s -> parse_rfc3339 s
228228- | None -> None
229229- in
230230-231231- let date_modified =
232232- match optional_string "date_modified" obj with
233233- | Some s -> parse_rfc3339 s
234234- | None -> None
235235- in
236236-237237- let authors =
238238- match optional_array "authors" obj with
239239- | Some arr ->
240240- let parsed = List.map parse_author arr in
241241- if parsed = [] then None else Some parsed
242242- | None -> None
243243- in
244244-245245- let tags =
246246- match optional_array "tags" obj with
247247- | Some arr ->
248248- let parsed = List.filter_map get_string arr in
249249- if parsed = [] then None else Some parsed
250250- | None -> None
251251- in
252252-253253- let language = optional_string "language" obj in
254254-255255- let attachments =
256256- match optional_array "attachments" obj with
257257- | Some arr ->
258258- let parsed = List.map parse_attachment arr in
259259- if parsed = [] then None else Some parsed
260260- | None -> None
261261- in
262262-263263- let parse_reference = function
264264- | Object obj ->
265265- let url = require_string "url" obj in
266266- let doi = optional_string "doi" obj in
267267- Reference.create ~url ?doi ()
268268- | _ -> raise (Invalid_feed "Reference must be an object")
269269- in
270270-271271- let references =
272272- match optional_array "_references" obj with
273273- | Some arr ->
274274- let parsed = List.map parse_reference arr in
275275- if parsed = [] then None else Some parsed
276276- | None -> None
277277- in
278278-279279- Item.create ~id ~content ?url ?external_url ?title ?summary ?image
280280- ?banner_image ?date_published ?date_modified ?authors ?tags ?language
281281- ?attachments ?references ()
282282-283283-let parse_item = function
284284- | Object obj -> parse_item_obj obj
285285- | _ -> raise (Invalid_feed "Item must be an object")
286286-287287-(* Parse Feed *)
288288-289289-let parse_feed_obj obj =
290290- let version = require_string "version" obj in
291291- let title = require_string "title" obj in
292292- let home_page_url = optional_string "home_page_url" obj in
293293- let feed_url = optional_string "feed_url" obj in
294294- let description = optional_string "description" obj in
295295- let user_comment = optional_string "user_comment" obj in
296296- let next_url = optional_string "next_url" obj in
297297- let icon = optional_string "icon" obj in
298298- let favicon = optional_string "favicon" obj in
299299- let language = optional_string "language" obj in
300300- let expired = optional_bool "expired" obj in
301301-302302- let authors =
303303- match optional_array "authors" obj with
304304- | Some arr ->
305305- let parsed = List.map parse_author arr in
306306- if parsed = [] then None else Some parsed
307307- | None -> None
308308- in
309309-310310- let hubs =
311311- match optional_array "hubs" obj with
312312- | Some arr ->
313313- let parsed = List.map parse_hub arr in
314314- if parsed = [] then None else Some parsed
315315- | None -> None
316316- in
317317-318318- let items =
319319- match optional_array "items" obj with
320320- | Some arr -> List.map parse_item arr
321321- | None -> []
322322- in
323323-324324- {
325325- version;
326326- title;
327327- home_page_url;
328328- feed_url;
329329- description;
330330- user_comment;
331331- next_url;
332332- icon;
333333- favicon;
334334- authors;
335335- language;
336336- expired;
337337- hubs;
338338- items;
339339- }
7979+let unknown t = t.unknown
34080341341-let of_jsonm dec =
342342- try
343343- let json = decode_value dec in
344344- match json with
345345- | Object obj -> Ok (parse_feed_obj obj)
346346- | _ -> error_msgf "Feed must be a JSON object"
347347- with
348348- | Invalid_feed msg -> error_msgf "%s" msg
8181+let equal a b =
8282+ a.title = b.title &&
8383+ a.items = b.items
34984350350-(* JSON serialization *)
8585+let pp ppf t =
8686+ Format.fprintf ppf "Feed: %s (%d items)" t.title (List.length t.items)
35187352352-let to_jsonm enc feed =
353353- let enc_field name value_fn =
354354- ignore (Jsonm.encode enc (`Lexeme (`Name name)));
355355- value_fn ()
356356- in
8888+let pp_summary ppf t =
8989+ Format.fprintf ppf "%s (%d items)" t.title (List.length t.items)
35790358358- let enc_string s =
359359- ignore (Jsonm.encode enc (`Lexeme (`String s)))
360360- in
9191+(* Jsont type *)
36192362362- let enc_bool b =
363363- ignore (Jsonm.encode enc (`Lexeme (`Bool b)))
9393+let jsont =
9494+ let kind = "JSON Feed" in
9595+ let doc = "A JSON Feed document" in
9696+ let unknown_mems : (Unknown.t, Jsont.json, Jsont.mem list) Jsont.Object.Mems.map =
9797+ let open Jsont.Object.Mems in
9898+ let dec_empty () = [] in
9999+ let dec_add _meta (name : string) value acc =
100100+ ((name, Jsont.Meta.none), value) :: acc
101101+ in
102102+ let dec_finish _meta mems =
103103+ List.rev_map (fun ((name, _meta), value) -> (name, value)) mems in
104104+ let enc = {
105105+ enc = fun (type acc) (f : Jsont.Meta.t -> string -> Jsont.json -> acc -> acc) unknown (acc : acc) ->
106106+ List.fold_left (fun acc (name, value) ->
107107+108108+ f Jsont.Meta.none name value acc
109109+ ) acc unknown
110110+ } in
111111+ map ~kind:"Unknown members" Jsont.json ~dec_empty ~dec_add ~dec_finish ~enc
364112 in
365113366366- let enc_opt enc_fn = function
367367- | None -> ()
368368- | Some v -> enc_fn v
114114+ (* Helper constructor that sets version automatically *)
115115+ let make_from_json _version title home_page_url feed_url description user_comment
116116+ next_url icon favicon authors language expired hubs items unknown =
117117+ {
118118+ version = "https://jsonfeed.org/version/1.1";
119119+ title;
120120+ home_page_url;
121121+ feed_url;
122122+ description;
123123+ user_comment;
124124+ next_url;
125125+ icon;
126126+ favicon;
127127+ authors;
128128+ language;
129129+ expired;
130130+ hubs;
131131+ items;
132132+ unknown;
133133+ }
369134 in
370135371371- let enc_list enc_fn lst =
372372- ignore (Jsonm.encode enc (`Lexeme `As));
373373- List.iter enc_fn lst;
374374- ignore (Jsonm.encode enc (`Lexeme `Ae))
375375- in
136136+ Jsont.Object.map ~kind ~doc make_from_json
137137+ |> Jsont.Object.mem "version" Jsont.string ~enc:version
138138+ |> Jsont.Object.mem "title" Jsont.string ~enc:title
139139+ |> Jsont.Object.opt_mem "home_page_url" Jsont.string ~enc:home_page_url
140140+ |> Jsont.Object.opt_mem "feed_url" Jsont.string ~enc:feed_url
141141+ |> Jsont.Object.opt_mem "description" Jsont.string ~enc:description
142142+ |> Jsont.Object.opt_mem "user_comment" Jsont.string ~enc:user_comment
143143+ |> Jsont.Object.opt_mem "next_url" Jsont.string ~enc:next_url
144144+ |> Jsont.Object.opt_mem "icon" Jsont.string ~enc:icon
145145+ |> Jsont.Object.opt_mem "favicon" Jsont.string ~enc:favicon
146146+ |> Jsont.Object.opt_mem "authors" (Jsont.list Author.jsont) ~enc:authors
147147+ |> Jsont.Object.opt_mem "language" Jsont.string ~enc:language
148148+ |> Jsont.Object.opt_mem "expired" Jsont.bool ~enc:expired
149149+ |> Jsont.Object.opt_mem "hubs" (Jsont.list Hub.jsont) ~enc:hubs
150150+ |> Jsont.Object.mem "items" (Jsont.list Item.jsont) ~enc:items
151151+ |> Jsont.Object.keep_unknown unknown_mems ~enc:unknown
152152+ |> Jsont.Object.finish
376153377377- let enc_author author =
378378- ignore (Jsonm.encode enc (`Lexeme `Os));
379379- (match Author.name author with
380380- | Some name -> enc_field "name" (fun () -> enc_string name)
381381- | None -> ());
382382- (match Author.url author with
383383- | Some url -> enc_field "url" (fun () -> enc_string url)
384384- | None -> ());
385385- (match Author.avatar author with
386386- | Some avatar -> enc_field "avatar" (fun () -> enc_string avatar)
387387- | None -> ());
388388- ignore (Jsonm.encode enc (`Lexeme `Oe))
389389- in
154154+(* Encoding and Decoding *)
390155391391- let enc_attachment att =
392392- ignore (Jsonm.encode enc (`Lexeme `Os));
393393- enc_field "url" (fun () -> enc_string (Attachment.url att));
394394- enc_field "mime_type" (fun () -> enc_string (Attachment.mime_type att));
395395- enc_opt (fun title -> enc_field "title" (fun () -> enc_string title))
396396- (Attachment.title att);
397397- enc_opt (fun size ->
398398- enc_field "size_in_bytes" (fun () ->
399399- ignore (Jsonm.encode enc (`Lexeme (`Float (Int64.to_float size))))))
400400- (Attachment.size_in_bytes att);
401401- enc_opt (fun dur ->
402402- enc_field "duration_in_seconds" (fun () ->
403403- ignore (Jsonm.encode enc (`Lexeme (`Float (float_of_int dur))))))
404404- (Attachment.duration_in_seconds att);
405405- ignore (Jsonm.encode enc (`Lexeme `Oe))
406406- in
156156+let decode ?layout ?locs ?file r =
157157+ Jsont_bytesrw.decode' ?layout ?locs ?file jsont r
407158408408- let enc_reference ref =
409409- ignore (Jsonm.encode enc (`Lexeme `Os));
410410- enc_field "url" (fun () -> enc_string (Reference.url ref));
411411- enc_opt (fun doi -> enc_field "doi" (fun () -> enc_string doi))
412412- (Reference.doi ref);
413413- enc_opt (fun cito_list ->
414414- enc_field "cito" (fun () ->
415415- enc_list (fun cito -> enc_string (Cito.to_string cito)) cito_list))
416416- (Reference.cito ref);
417417- ignore (Jsonm.encode enc (`Lexeme `Oe))
418418- in
159159+let decode_string ?layout ?locs ?file s =
160160+ Jsont_bytesrw.decode_string' ?layout ?locs ?file jsont s
419161420420- let enc_hub hub =
421421- ignore (Jsonm.encode enc (`Lexeme `Os));
422422- enc_field "type" (fun () -> enc_string (Hub.type_ hub));
423423- enc_field "url" (fun () -> enc_string (Hub.url hub));
424424- ignore (Jsonm.encode enc (`Lexeme `Oe))
425425- in
426426-427427- let enc_item item =
428428- ignore (Jsonm.encode enc (`Lexeme `Os));
429429- enc_field "id" (fun () -> enc_string (Item.id item));
430430-431431- (* Encode content *)
432432- (match Item.content item with
433433- | `Html html ->
434434- enc_field "content_html" (fun () -> enc_string html)
435435- | `Text text ->
436436- enc_field "content_text" (fun () -> enc_string text)
437437- | `Both (html, text) ->
438438- enc_field "content_html" (fun () -> enc_string html);
439439- enc_field "content_text" (fun () -> enc_string text));
440440-441441- enc_opt (fun url -> enc_field "url" (fun () -> enc_string url))
442442- (Item.url item);
443443- enc_opt (fun url -> enc_field "external_url" (fun () -> enc_string url))
444444- (Item.external_url item);
445445- enc_opt (fun title -> enc_field "title" (fun () -> enc_string title))
446446- (Item.title item);
447447- enc_opt (fun summary -> enc_field "summary" (fun () -> enc_string summary))
448448- (Item.summary item);
449449- enc_opt (fun img -> enc_field "image" (fun () -> enc_string img))
450450- (Item.image item);
451451- enc_opt (fun img -> enc_field "banner_image" (fun () -> enc_string img))
452452- (Item.banner_image item);
453453- enc_opt (fun date -> enc_field "date_published" (fun () -> enc_string (format_rfc3339 date)))
454454- (Item.date_published item);
455455- enc_opt (fun date -> enc_field "date_modified" (fun () -> enc_string (format_rfc3339 date)))
456456- (Item.date_modified item);
457457- enc_opt (fun authors ->
458458- enc_field "authors" (fun () -> enc_list enc_author authors))
459459- (Item.authors item);
460460- enc_opt (fun tags ->
461461- enc_field "tags" (fun () -> enc_list enc_string tags))
462462- (Item.tags item);
463463- enc_opt (fun lang -> enc_field "language" (fun () -> enc_string lang))
464464- (Item.language item);
465465- enc_opt (fun atts ->
466466- enc_field "attachments" (fun () -> enc_list enc_attachment atts))
467467- (Item.attachments item);
468468- enc_opt (fun refs ->
469469- enc_field "_references" (fun () -> enc_list enc_reference refs))
470470- (Item.references item);
471471-472472- ignore (Jsonm.encode enc (`Lexeme `Oe))
473473- in
162162+let encode ?format ?number_format feed ~eod w =
163163+ Jsont_bytesrw.encode' ?format ?number_format jsont feed ~eod w
474164475475- (* Encode the feed *)
476476- ignore (Jsonm.encode enc (`Lexeme `Os));
477477- enc_field "version" (fun () -> enc_string feed.version);
478478- enc_field "title" (fun () -> enc_string feed.title);
479479- enc_opt (fun url -> enc_field "home_page_url" (fun () -> enc_string url))
480480- feed.home_page_url;
481481- enc_opt (fun url -> enc_field "feed_url" (fun () -> enc_string url))
482482- feed.feed_url;
483483- enc_opt (fun desc -> enc_field "description" (fun () -> enc_string desc))
484484- feed.description;
485485- enc_opt (fun comment -> enc_field "user_comment" (fun () -> enc_string comment))
486486- feed.user_comment;
487487- enc_opt (fun url -> enc_field "next_url" (fun () -> enc_string url))
488488- feed.next_url;
489489- enc_opt (fun icon -> enc_field "icon" (fun () -> enc_string icon))
490490- feed.icon;
491491- enc_opt (fun favicon -> enc_field "favicon" (fun () -> enc_string favicon))
492492- feed.favicon;
493493- enc_opt (fun authors ->
494494- enc_field "authors" (fun () -> enc_list enc_author authors))
495495- feed.authors;
496496- enc_opt (fun lang -> enc_field "language" (fun () -> enc_string lang))
497497- feed.language;
498498- enc_opt (fun expired -> enc_field "expired" (fun () -> enc_bool expired))
499499- feed.expired;
500500- enc_opt (fun hubs ->
501501- enc_field "hubs" (fun () -> enc_list enc_hub hubs))
502502- feed.hubs;
503503- enc_field "items" (fun () -> enc_list enc_item feed.items);
504504- ignore (Jsonm.encode enc (`Lexeme `Oe));
505505- ignore (Jsonm.encode enc `End)
165165+let encode_string ?format ?number_format feed =
166166+ Jsont_bytesrw.encode_string' ?format ?number_format jsont feed
506167507168let of_string s =
508508- let dec = Jsonm.decoder (`String s) in
509509- of_jsonm dec
169169+ decode_string s
510170511171let to_string ?(minify=false) feed =
512512- let buf = Buffer.create 1024 in
513513- let enc = Jsonm.encoder ~minify (`Buffer buf) in
514514- to_jsonm enc feed;
515515- Buffer.contents buf
172172+ let format = if minify then Jsont.Minify else Jsont.Indent in
173173+ encode_string ~format feed
516174517175(* Validation *)
518176···554212 | None -> ())
555213 ) feed.items;
556214557557- if !errors = [] then Ok ()
558558- else Error (List.rev !errors)
559559-560560-(* Comparison *)
561561-562562-let equal a b =
563563- a.version = b.version &&
564564- a.title = b.title &&
565565- a.home_page_url = b.home_page_url &&
566566- a.feed_url = b.feed_url &&
567567- a.description = b.description &&
568568- a.user_comment = b.user_comment &&
569569- a.next_url = b.next_url &&
570570- a.icon = b.icon &&
571571- a.favicon = b.favicon &&
572572- a.language = b.language &&
573573- a.expired = b.expired &&
574574- (* Note: We're doing structural equality on items *)
575575- List.length a.items = List.length b.items
576576-577577-(* Pretty printing *)
578578-579579-let pp_summary ppf feed =
580580- Format.fprintf ppf "%s (%d items)" feed.title (List.length feed.items)
581581-582582-let pp ppf feed =
583583- Format.fprintf ppf "Feed: %s" feed.title;
584584- (match feed.home_page_url with
585585- | Some url -> Format.fprintf ppf " (%s)" url
586586- | None -> ());
587587- Format.fprintf ppf "@\n";
588588-589589- Format.fprintf ppf " Items: %d@\n" (List.length feed.items);
590590-591591- (match feed.authors with
592592- | Some authors when authors <> [] ->
593593- Format.fprintf ppf " Authors: ";
594594- List.iteri (fun i author ->
595595- if i > 0 then Format.fprintf ppf ", ";
596596- Format.fprintf ppf "%a" Author.pp author
597597- ) authors;
598598- Format.fprintf ppf "@\n"
599599- | _ -> ());
600600-601601- (match feed.language with
602602- | Some lang -> Format.fprintf ppf " Language: %s@\n" lang
603603- | None -> ())
215215+ match !errors with
216216+ | [] -> Ok ()
217217+ | errs -> Error (List.rev errs)
+81-292
lib/jsonfeed.mli
···11-(** JSON Feed format parser and serializer.
11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
2533- This library implements the JSON Feed specification version 1.1, providing
44- type-safe parsing and serialization of JSON Feed documents. JSON Feed is a
55- syndication format similar to RSS and Atom, but using JSON instead of XML.
66-77- {b Quick Start:}
88- {[
99- (* Create a simple feed *)
1010- let feed = Jsonfeed.create
1111- ~title:"My Blog"
1212- ~home_page_url:"https://example.com"
1313- ~feed_url:"https://example.com/feed.json"
1414- ~items:[
1515- Item.create
1616- ~id:"https://example.com/post/1"
1717- ~content:(Item.Html "<p>Hello, world!</p>")
1818- ~title:"First Post"
1919- ()
2020- ]
2121- ()
2222-2323- (* Serialize to string *)
2424- let json = Jsonfeed.to_string feed
2525-2626- (* Parse from string *)
2727- match Jsonfeed.of_string json with
2828- | Ok feed -> Printf.printf "Feed: %s\n" (Jsonfeed.title feed)
2929- | Error err -> Printf.eprintf "Error: %s\n" err
3030- ]}
66+(** JSON Feed format parser and serializer using Jsont and Bytesrw.
317328 @see <https://www.jsonfeed.org/version/1.1/> JSON Feed Specification *)
339···3511(** The type representing a complete JSON Feed. *)
3612type t
37133838-(** Exception raised when attempting to parse an invalid feed. *)
3939-exception Invalid_feed of string
40144141-(** {1 Construction} *)
1515+(** {1 Unknown Fields} *)
42164343-(** [create ~title ?home_page_url ?feed_url ?description ?user_comment ?next_url
4444- ?icon ?favicon ?authors ?language ?expired ?hubs ~items ()]
4545- creates a JSON Feed.
1717+module Unknown : sig
1818+ type t = (string * Jsont.json) list
1919+ (** Unknown/unrecognized JSON object members.
2020+ Useful for preserving fields from custom extensions or future spec versions. *)
46214747- @param title The name of the feed (required)
4848- @param home_page_url The URL of the resource the feed describes
4949- @param feed_url The URL of the feed itself (serves as unique identifier)
5050- @param description Additional information about the feed
5151- @param user_comment A description of the feed's purpose for humans reading the raw JSON
5252- @param next_url URL of the next page of items (for pagination)
5353- @param icon The feed's icon URL (should be square, 512x512 or larger)
5454- @param favicon The feed's favicon URL (should be square, 64x64 or larger)
5555- @param authors The feed's default authors (inherited by items without authors)
5656- @param language The primary language of the feed (RFC 5646 format, e.g. ["en-US"])
5757- @param expired Whether the feed will update again ([true] means no more updates)
5858- @param hubs Endpoints for real-time notifications
5959- @param items The list of feed items (required)
2222+ val empty : t
2323+ (** [empty] is the empty list of unknown fields. *)
2424+2525+ val is_empty : t -> bool
2626+ (** [is_empty u] returns [true] if there are no unknown fields. *)
2727+end
2828+2929+3030+(** {1 Jsont Type} *)
3131+3232+val jsont : t Jsont.t
3333+(** Declarative JSON type for JSON feeds.
3434+3535+ Maps the complete JSON Feed 1.1 specification including all required
3636+ and optional fields. *)
60376161- {b Examples:}
6262- {[
6363- (* Minimal feed *)
6464- let feed = Jsonfeed.create
6565- ~title:"My Blog"
6666- ~items:[] ()
67386868- (* Full-featured blog feed *)
6969- let feed = Jsonfeed.create
7070- ~title:"Example Blog"
7171- ~home_page_url:"https://example.com"
7272- ~feed_url:"https://example.com/feed.json"
7373- ~description:"A blog about OCaml and functional programming"
7474- ~icon:"https://example.com/icon.png"
7575- ~authors:[
7676- Author.create
7777- ~name:"Jane Doe"
7878- ~url:"https://example.com/about"
7979- ()
8080- ]
8181- ~language:"en-US"
8282- ~items:[
8383- Item.create
8484- ~id:"https://example.com/posts/1"
8585- ~content:(Item.Html "<p>First post</p>")
8686- ~title:"Hello World"
8787- ();
8888- Item.create
8989- ~id:"https://example.com/posts/2"
9090- ~content:(Item.Html "<p>Second post</p>")
9191- ~title:"Another Post"
9292- ()
9393- ]
9494- ()
3939+(** {1 Construction} *)
95409696- (* Podcast feed with hubs *)
9797- let hub = Hub.create
9898- ~type_:"WebSub"
9999- ~url:"https://pubsubhubbub.appspot.com/"
100100- () in
101101- let feed = Jsonfeed.create
102102- ~title:"My Podcast"
103103- ~home_page_url:"https://podcast.example.com"
104104- ~feed_url:"https://podcast.example.com/feed.json"
105105- ~hubs:[hub]
106106- ~items:[
107107- Item.create
108108- ~id:"https://podcast.example.com/episodes/1"
109109- ~content:(Item.Html "<p>Episode description</p>")
110110- ~title:"Episode 1"
111111- ~attachments:[
112112- Attachment.create
113113- ~url:"https://podcast.example.com/ep1.mp3"
114114- ~mime_type:"audio/mpeg"
115115- ~duration_in_seconds:1800
116116- ()
117117- ]
118118- ()
119119- ]
120120- ()
121121- ]} *)
12241val create :
12342 title:string ->
12443 ?home_page_url:string ->
···13655 unit ->
13756 t
138575858+val make :
5959+ title:string ->
6060+ ?home_page_url:string ->
6161+ ?feed_url:string ->
6262+ ?description:string ->
6363+ ?user_comment:string ->
6464+ ?next_url:string ->
6565+ ?icon:string ->
6666+ ?favicon:string ->
6767+ ?authors:Author.t list ->
6868+ ?language:string ->
6969+ ?expired:bool ->
7070+ ?hubs:Hub.t list ->
7171+ items:Item.t list ->
7272+ ?unknown:Unknown.t ->
7373+ unit ->
7474+ t
13975140140-(** {1 Accessors} *)
14176142142-(** [version t] returns the JSON Feed version URL.
7777+(** {1 Accessors} *)
14378144144- This is always ["https://jsonfeed.org/version/1.1"] for feeds created
145145- by this library, but may differ when parsing external feeds. *)
14679val version : t -> string
147147-148148-(** [title t] returns the feed's title. *)
14980val title : t -> string
150150-151151-(** [home_page_url t] returns the feed's home page URL, if set. *)
15281val home_page_url : t -> string option
153153-154154-(** [feed_url t] returns the feed's URL, if set. *)
15582val feed_url : t -> string option
156156-157157-(** [description t] returns the feed's description, if set. *)
15883val description : t -> string option
159159-160160-(** [user_comment t] returns the feed's user comment, if set. *)
16184val user_comment : t -> string option
162162-163163-(** [next_url t] returns the URL for the next page of items, if set. *)
16485val next_url : t -> string option
165165-166166-(** [icon t] returns the feed's icon URL, if set. *)
16786val icon : t -> string option
168168-169169-(** [favicon t] returns the feed's favicon URL, if set. *)
17087val favicon : t -> string option
171171-172172-(** [authors t] returns the feed's default authors, if set. *)
17388val authors : t -> Author.t list option
174174-175175-(** [language t] returns the feed's primary language, if set. *)
17689val language : t -> string option
177177-178178-(** [expired t] returns whether the feed will update again. *)
17990val expired : t -> bool option
180180-181181-(** [hubs t] returns the feed's hub endpoints, if set. *)
18291val hubs : t -> Hub.t list option
183183-184184-(** [items t] returns the feed's items. *)
18592val items : t -> Item.t list
9393+val unknown : t -> Unknown.t
1869418795188188-(** {1 Parsing and Serialization} *)
9696+(** {1 Encoding and Decoding with Bytesrw} *)
18997190190-(** Error type for parsing operations. *)
191191-type error = string
192192-193193-(** [of_jsonm decoder] parses a JSON Feed from a Jsonm decoder.
9898+val decode :
9999+ ?layout:bool -> ?locs:bool -> ?file:string ->
100100+ Bytesrw.Bytes.Reader.t -> (t, Jsont.Error.t) result
101101+(** [decode r] decodes a JSON Feed from bytesrw reader [r].
194102195195- This is the lowest-level parsing function, suitable for integration
196196- with streaming JSON processing pipelines.
103103+ @param layout Preserve whitespace for round-tripping (default: false)
104104+ @param locs Track locations for better error messages (default: false)
105105+ @param file Source file name for error reporting *)
197106198198- @param decoder A Jsonm decoder positioned at the start of a JSON Feed document
199199- @return [Ok feed] on success, [Error err] on parse error
107107+val decode_string :
108108+ ?layout:bool -> ?locs:bool -> ?file:string ->
109109+ string -> (t, Jsont.Error.t) result
110110+(** [decode_string s] decodes a JSON Feed from string [s]. *)
200111201201- {b Example:}
202202- {[
203203- let decoder = Jsonm.decoder (`String json_string) in
204204- match Jsonfeed.of_jsonm decoder with
205205- | Ok feed -> (* process feed *)
206206- | Error err -> (* handle error *)
207207- ]} *)
208208-val of_jsonm : Jsonm.decoder -> (t, error) result
209209-210210-(** [to_jsonm encoder feed] serializes a JSON Feed to a Jsonm encoder.
211211-212212- This is the lowest-level serialization function, suitable for integration
213213- with streaming JSON generation pipelines.
214214-215215- @param encoder A Jsonm encoder
216216- @param feed The feed to serialize
217217-218218- {b Example:}
219219- {[
220220- let buffer = Buffer.create 1024 in
221221- let encoder = Jsonm.encoder (`Buffer buffer) in
222222- Jsonfeed.to_jsonm encoder feed;
223223- let json = Buffer.contents buffer
224224- ]} *)
225225-val to_jsonm : Jsonm.encoder -> t -> unit
226226-227227-(** [of_string s] parses a JSON Feed from a string.
228228-229229- @param s A JSON string containing a JSON Feed document
230230- @return [Ok feed] on success, [Error err] on parse error
231231-232232- {b Example:}
233233- {[
234234- let json = {|{
235235- "version": "https://jsonfeed.org/version/1.1",
236236- "title": "My Feed",
237237- "items": []
238238- }|} in
239239- match Jsonfeed.of_string json with
240240- | Ok feed -> Printf.printf "Parsed: %s\n" (Jsonfeed.title feed)
241241- | Error err -> Printf.eprintf "Error: %s\n" err
242242- ]} *)
243243-val of_string : string -> (t, error) result
244244-245245-(** [to_string ?minify feed] serializes a JSON Feed to a string.
246246-247247- @param minify If [true], produces compact JSON without whitespace.
248248- If [false] (default), produces indented, human-readable JSON.
249249- @param feed The feed to serialize
250250- @return A JSON string
251251-252252- {b Example:}
253253- {[
254254- let json = Jsonfeed.to_string feed
255255- let compact = Jsonfeed.to_string ~minify:true feed
256256- ]} *)
257257-val to_string : ?minify:bool -> t -> string
258258-259259-260260-(** {1 Date Utilities} *)
261261-262262-(** [parse_rfc3339 s] parses an RFC 3339 date/time string.
263263-264264- This function parses timestamps in the format required by JSON Feed,
265265- such as ["2024-11-03T10:30:00Z"] or ["2024-11-03T10:30:00-08:00"].
266266-267267- @param s An RFC 3339 formatted date/time string
268268- @return [Some time] on success, [None] if the string is invalid
269269-270270- {b Examples:}
271271- {[
272272- parse_rfc3339 "2024-11-03T10:30:00Z"
273273- (* returns Some time *)
112112+val encode :
113113+ ?format:Jsont.format -> ?number_format:Jsont.number_format ->
114114+ t -> eod:bool -> Bytesrw.Bytes.Writer.t -> (unit, Jsont.Error.t) result
115115+(** [encode feed w] encodes [feed] to bytesrw writer [w].
274116275275- parse_rfc3339 "2024-11-03T10:30:00-08:00"
276276- (* returns Some time *)
117117+ @param format Output formatting: [Jsont.Minify] or [Jsont.Indent] (default: Minify)
118118+ @param number_format Printf format for numbers (default: "%.16g")
119119+ @param eod Write end-of-data marker *)
277120278278- parse_rfc3339 "invalid"
279279- (* returns None *)
280280- ]} *)
281281-val parse_rfc3339 : string -> Ptime.t option
121121+val encode_string :
122122+ ?format:Jsont.format -> ?number_format:Jsont.number_format ->
123123+ t -> (string, Jsont.Error.t) result
124124+(** [encode_string feed] encodes [feed] to a string. *)
282125283283-(** [format_rfc3339 time] formats a timestamp as an RFC 3339 string.
284126285285- The output uses UTC timezone (Z suffix) and includes fractional seconds
286286- if the timestamp has sub-second precision.
127127+(** {1 Convenience Functions} *)
287128288288- @param time A Ptime timestamp
289289- @return An RFC 3339 formatted string
129129+val of_string : string -> (t, Jsont.Error.t) result
130130+(** Alias for [decode_string] with default options. *)
290131291291- {b Example:}
292292- {[
293293- let now = Ptime_clock.now () in
294294- let s = format_rfc3339 now
295295- (* returns "2024-11-03T10:30:45.123Z" or similar *)
296296- ]} *)
297297-val format_rfc3339 : Ptime.t -> string
132132+val to_string : ?minify:bool -> t -> (string, Jsont.Error.t) result
133133+(** [to_string feed] encodes [feed] to string.
134134+ @param minify Use compact format (true) or indented (false, default) *)
298135299136300137(** {1 Validation} *)
301138302302-(** [validate feed] validates a JSON Feed.
303303-304304- Checks that:
305305- - All required fields are present
306306- - All items have unique IDs
307307- - All items have valid content
308308- - All URLs are well-formed (if possible)
309309- - Authors have at least one field set
310310-311311- @param feed The feed to validate
312312- @return [Ok ()] if valid, [Error errors] with a list of validation issues
313313-314314- {b Example:}
315315- {[
316316- match Jsonfeed.validate feed with
317317- | Ok () -> (* feed is valid *)
318318- | Error errors ->
319319- List.iter (Printf.eprintf "Validation error: %s\n") errors
320320- ]} *)
321139val validate : t -> (unit, string list) result
140140+(** [validate feed] validates the feed structure.
141141+ Checks for unique item IDs, valid content, etc. *)
322142323143324144(** {1 Comparison} *)
325145326326-(** [equal a b] tests equality between two feeds.
327327-328328- Feeds are compared structurally, including all fields and items. *)
329146val equal : t -> t -> bool
147147+(** [equal a b] tests equality between two feeds. *)
330148331149332150(** {1 Pretty Printing} *)
333151334334-(** [pp ppf feed] pretty prints a feed to the formatter.
335335-336336- The output is human-readable and suitable for debugging. It shows
337337- the feed's metadata and a summary of items.
338338-339339- {b Example output:}
340340- {v
341341- Feed: My Blog (https://example.com)
342342- Items: 2
343343- Authors: Jane Doe
344344- Language: en-US
345345- v} *)
346152val pp : Format.formatter -> t -> unit
347347-348348-(** [pp_summary ppf feed] prints a brief summary of the feed.
349349-350350- Shows only the title and item count.
351351-352352- {b Example output:}
353353- {v My Blog (2 items) v} *)
354153val pp_summary : Format.formatter -> t -> unit
355154356155357357-(** {1 Feed Content} *)
156156+(** {1 Submodules} *)
358157359359-(** Author information for feeds and items. *)
158158+module Rfc3339 = Rfc3339
159159+module Cito = Cito
360160module Author = Author
361361-362362-(** Attachments for feed items (audio, video, downloads). *)
363161module Attachment = Attachment
364364-365365-(** Hub endpoints for real-time notifications. *)
366162module Hub = Hub
367367-368368-(** Feed items (posts, episodes, entries). *)
163163+module Reference = Reference
369164module Item = Item
370370-371371-(** References to cited sources in items (extension). *)
372372-module Reference = Reference
373373-374374-(** Citation Typing Ontology annotations for references (extension). *)
375375-module Cito = Cito
+47-1
lib/reference.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+module Unknown = struct
77+ type t = (string * Jsont.json) list
88+99+ let empty = []
1010+ let is_empty = function [] -> true | _ -> false
1111+end
1212+113type t = {
214 url : string;
315 doi : string option;
416 cito : Cito.t list option;
1717+ unknown : Unknown.t;
518}
61977-let create ~url ?doi ?cito () = { url; doi; cito }
2020+let make ~url ?doi ?cito ?(unknown = Unknown.empty) () =
2121+ { url; doi; cito; unknown }
2222+2323+let create ~url ?doi ?cito () =
2424+ make ~url ?doi ?cito ()
825926let url t = t.url
1027let doi t = t.doi
1128let cito t = t.cito
2929+let unknown t = t.unknown
12301331let equal a b = String.equal a.url b.url
1432···1836 match t.doi with
1937 | Some d -> fprintf ppf " [DOI: %s]" d
2038 | None -> ()
3939+4040+let jsont =
4141+ let kind = "Reference" in
4242+ let doc = "A reference to a cited source" in
4343+ let unknown_mems : (Unknown.t, Jsont.json, Jsont.mem list) Jsont.Object.Mems.map =
4444+ let open Jsont.Object.Mems in
4545+ let dec_empty () = [] in
4646+ let dec_add _meta (name : string) value acc =
4747+ ((name, Jsont.Meta.none), value) :: acc
4848+ in
4949+ let dec_finish _meta mems =
5050+ List.rev_map (fun ((name, _meta), value) -> (name, value)) mems in
5151+ let enc = {
5252+ enc = fun (type acc) (f : Jsont.Meta.t -> string -> Jsont.json -> acc -> acc) unknown (acc : acc) ->
5353+ List.fold_left (fun acc (name, value) ->
5454+5555+ f Jsont.Meta.none name value acc
5656+ ) acc unknown
5757+ } in
5858+ map ~kind:"Unknown members" Jsont.json ~dec_empty ~dec_add ~dec_finish ~enc
5959+ in
6060+ let make_obj url doi cito unknown = make ~url ?doi ?cito ~unknown () in
6161+ Jsont.Object.map ~kind ~doc make_obj
6262+ |> Jsont.Object.mem "url" Jsont.string ~enc:url
6363+ |> Jsont.Object.opt_mem "doi" Jsont.string ~enc:doi
6464+ |> Jsont.Object.opt_mem "cito" (Jsont.list Cito.jsont) ~enc:cito
6565+ |> Jsont.Object.keep_unknown unknown_mems ~enc:unknown
6666+ |> Jsont.Object.finish
+46-12
lib/reference.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+16(** References extension for JSON Feed items.
2738 This implements the references extension that allows items to cite sources.
···1116type t
121713181919+(** {1 Unknown Fields} *)
2020+2121+module Unknown : sig
2222+ type t = (string * Jsont.json) list
2323+ (** Unknown/unrecognized JSON object members.
2424+ Useful for preserving fields from custom extensions or future spec versions. *)
2525+2626+ val empty : t
2727+ (** [empty] is the empty list of unknown fields. *)
2828+2929+ val is_empty : t -> bool
3030+ (** [is_empty u] returns [true] if there are no unknown fields. *)
3131+end
3232+3333+3434+(** {1 Jsont Type} *)
3535+3636+val jsont : t Jsont.t
3737+(** Declarative JSON type for references.
3838+3939+ Maps JSON objects with "url" (required) and optional "doi" and "cito" fields. *)
4040+4141+1442(** {1 Construction} *)
15434444+val create :
4545+ url:string ->
4646+ ?doi:string ->
4747+ ?cito:Cito.t list ->
4848+ unit ->
4949+ t
1650(** [create ~url ?doi ?cito ()] creates a reference.
17511852 @param url Unique URL for the reference (required).
···3973 ~doi:"10.5281/zenodo.16755947"
4074 ~cito:[`CitesAsRecommendedReading; `UsesMethodIn]
4175 ()
4242-4343- (* Reference with custom CiTO term *)
4444- let ref4 = Reference.create
4545- ~url:"https://example.com/paper"
4646- ~cito:[`Other "customIntent"]
4747- ()
4876 ]} *)
4949-val create :
7777+7878+val make :
5079 url:string ->
5180 ?doi:string ->
5281 ?cito:Cito.t list ->
8282+ ?unknown:Unknown.t ->
5383 unit ->
5484 t
8585+(** [make] is like {!create} but allows setting unknown fields. *)
558656875788(** {1 Accessors} *)
58895959-(** [url t] returns the reference's URL. *)
6090val url : t -> string
9191+(** [url t] returns the reference's URL. *)
61929393+val doi : t -> string option
6294(** [doi t] returns the reference's DOI, if set. *)
6363-val doi : t -> string option
64959696+val cito : t -> Cito.t list option
6597(** [cito t] returns the reference's CiTO annotations, if set. *)
6666-val cito : t -> Cito.t list option
9898+9999+val unknown : t -> Unknown.t
100100+(** [unknown t] returns unrecognized fields from the JSON. *)
671016810269103(** {1 Comparison} *)
70104105105+val equal : t -> t -> bool
71106(** [equal a b] tests equality between two references.
7210773108 References are considered equal if they have the same URL. *)
7474-val equal : t -> t -> bool
751097611077111(** {1 Pretty Printing} *)
78112113113+val pp : Format.formatter -> t -> unit
79114(** [pp ppf t] pretty prints a reference to the formatter.
8011581116 {b Example output:}
82117 {v https://doi.org/10.5281/zenodo.16755947 [DOI: 10.5281/zenodo.16755947] v} *)
8383-val pp : Format.formatter -> t -> unit
+25
lib/rfc3339.ml
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+let parse s =
77+ match Ptime.of_rfc3339 s with
88+ | Ok (t, _, _) -> Some t
99+ | Error _ -> None
1010+1111+let format t =
1212+ Ptime.to_rfc3339 ~frac_s:6 ~tz_offset_s:0 t
1313+1414+let pp ppf t =
1515+ Format.pp_print_string ppf (format t)
1616+1717+let jsont =
1818+ let kind = "RFC 3339 timestamp" in
1919+ let doc = "An RFC 3339 date-time string" in
2020+ let dec s = match parse s with
2121+ | Some t -> t
2222+ | None -> Jsont.Error.msgf Jsont.Meta.none "%s: invalid RFC 3339 timestamp: %S" kind s
2323+ in
2424+ let enc = format in
2525+ Jsont.map ~kind ~doc ~dec ~enc Jsont.string
+45
lib/rfc3339.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2024 Anil Madhavapeddy. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** RFC 3339 date/time handling for JSON Feed.
77+88+ Provides parsing, formatting, and jsont combinators for RFC 3339 timestamps
99+ as required by the JSON Feed specification.
1010+1111+ @see <https://www.rfc-editor.org/rfc/rfc3339> RFC 3339 *)
1212+1313+1414+val jsont : Ptime.t Jsont.t
1515+(** [jsont] is a bidirectional JSON type for RFC 3339 timestamps.
1616+1717+ On decode: accepts JSON strings in RFC 3339 format (e.g., "2024-11-03T10:30:00Z")
1818+ On encode: produces UTC timestamps with 'Z' suffix
1919+2020+ {b Example:}
2121+ {[
2222+ let time = Ptime.of_float_s (Unix.time ()) |> Option.get in
2323+ Jsont_bytesrw.encode_string Rfc3339.jsont time
2424+ ]} *)
2525+2626+val parse : string -> Ptime.t option
2727+(** [parse s] parses an RFC 3339 timestamp string.
2828+2929+ Accepts various formats:
3030+ - "2024-11-03T10:30:00Z" (UTC)
3131+ - "2024-11-03T10:30:00-08:00" (with timezone offset)
3232+ - "2024-11-03T10:30:00.123Z" (with fractional seconds)
3333+3434+ Returns [None] if the string is not valid RFC 3339. *)
3535+3636+val format : Ptime.t -> string
3737+(** [format t] formats a timestamp as RFC 3339.
3838+3939+ Always uses UTC timezone (Z suffix) and includes fractional seconds
4040+ if the timestamp has sub-second precision.
4141+4242+ {b Example output:} ["2024-11-03T10:30:45.123Z"] *)
4343+4444+val pp : Format.formatter -> Ptime.t -> unit
4545+(** [pp ppf t] pretty prints a timestamp in RFC 3339 format. *)
···1919 () in
20202121 (* Serialize to JSON *)
2222- let json = Jsonfeed.to_string feed in
2222+ let json = match Jsonfeed.to_string feed with
2323+ | Ok s -> s
2424+ | Error e -> failwith (Jsont.Error.to_string e)
2525+ in
23262427 (* Print it *)
2528 Printf.printf "Generated JSON Feed:\n%s\n\n" json;