···11+{
22+ "version": "https://jsonfeed.org/version/1",
33+ "title": "JSON Feed",
44+ "icon": "https://cdn.micro.blog/jsonfeed/avatar.jpg",
55+ "home_page_url": "https://www.jsonfeed.org/",
66+ "feed_url": "https://www.jsonfeed.org/feed.json",
77+ "items": [
88+ {
99+ "id": "http://jsonfeed.micro.blog/2020/08/07/json-feed-version.html",
1010+ "title": "JSON Feed version 1.1",
1111+ "content_html": "<p>We’ve updated the spec to <a href=\"https://jsonfeed.org/version/1.1\">version 1.1</a>. It’s a minor update to JSON Feed, clarifying a few things in the spec and adding a couple new fields such as <code>authors</code> and <code>language</code>.</p>\n\n<p>For version 1.1, we’re starting to move to the more specific MIME type <code>application/feed+json</code>. Clients that parse HTML to discover feeds should prefer that MIME type, while still falling back to accepting <code>application/json</code> too.</p>\n\n<p>The <a href=\"https://jsonfeed.org/code/\">code page</a> has also been updated with several new code libraries and apps that support JSON Feed.</p>\n",
1212+1313+ "date_published": "2020-08-07T11:44:36-05:00",
1414+ "url": "https://www.jsonfeed.org/2020/08/07/json-feed-version.html"
1515+ },
1616+ {
1717+ "id": "http://jsonfeed.micro.blog/2017/05/17/announcing-json-feed.html",
1818+ "title": "Announcing JSON Feed",
1919+ "content_html": "\n\n<p>We — Manton Reece and Brent Simmons — have noticed that JSON has become the developers’ choice for APIs, and that developers will often go out of their way to avoid XML. JSON is simpler to read and write, and it’s less prone to bugs.</p>\n\n<p>So we developed JSON Feed, a format similar to <a href=\"http://cyber.harvard.edu/rss/rss.html\">RSS</a> and <a href=\"https://tools.ietf.org/html/rfc4287\">Atom</a> but in JSON. It reflects the lessons learned from our years of work reading and publishing feeds.</p>\n\n<p><a href=\"https://jsonfeed.org/version/1\">See the spec</a>. It’s at version 1, which may be the only version ever needed. If future versions are needed, version 1 feeds will still be valid feeds.</p>\n\n<h4 id=\"notes\">Notes</h4>\n\n<p>We have a <a href=\"https://github.com/manton/jsonfeed-wp\">WordPress plugin</a> and, coming soon, a JSON Feed Parser for Swift. As more code is written, by us and others, we’ll update the <a href=\"https://jsonfeed.org/code\">code</a> page.</p>\n\n<p>See <a href=\"https://jsonfeed.org/mappingrssandatom\">Mapping RSS and Atom to JSON Feed</a> for more on the similarities between the formats.</p>\n\n<p>This website — the Markdown files and supporting resources — <a href=\"https://github.com/brentsimmons/JSONFeed\">is up on GitHub</a>, and you’re welcome to comment there.</p>\n\n<p>This website is also a blog, and you can subscribe to the <a href=\"https://jsonfeed.org/xml/rss.xml\">RSS feed</a> or the <a href=\"https://jsonfeed.org/feed.json\">JSON feed</a> (if your reader supports it).</p>\n\n<p>We worked with a number of people on this over the course of several months. We list them, and thank them, at the bottom of the <a href=\"https://jsonfeed.org/version/1\">spec</a>. But — most importantly — <a href=\"http://furbo.org/\">Craig Hockenberry</a> spent a little time making it look pretty. :)</p>\n",
2020+2121+ "date_published": "2017-05-17T10:02:12-05:00",
2222+ "url": "https://www.jsonfeed.org/2017/05/17/announcing-json-feed.html"
2323+ }
2424+ ]
2525+}
+162
example/feed_example.ml
···11+(** Example: Creating and serializing a JSON Feed
22+33+ This demonstrates:
44+ - Creating authors
55+ - Creating items with different content types
66+ - Creating a complete feed
77+ - Serializing to JSON string and file *)
88+99+open Jsonfeed
1010+1111+(* Helper to write feed to Eio flow *)
1212+let to_flow flow feed =
1313+ let s = Jsonfeed.to_string feed in
1414+ Eio.Flow.copy_string s flow
1515+1616+let create_blog_feed () =
1717+ (* Create some authors *)
1818+ let jane = Author.create
1919+ ~name:"Jane Doe"
2020+ ~url:"https://example.com/authors/jane"
2121+ ~avatar:"https://example.com/avatars/jane.png"
2222+ () in
2323+2424+ let john = Author.create
2525+ ~name:"John Smith"
2626+ ~url:"https://example.com/authors/john"
2727+ () in
2828+2929+ (* Create items with different content types *)
3030+ let item1 = Item.create
3131+ ~id:"https://example.com/posts/1"
3232+ ~url:"https://example.com/posts/1"
3333+ ~title:"Introduction to OCaml"
3434+ ~content:(`Both (
3535+ "<p>OCaml is a powerful functional programming language.</p>",
3636+ "OCaml is a powerful functional programming language."
3737+ ))
3838+ ~date_published:(Jsonfeed.parse_rfc3339 "2024-11-01T10:00:00Z" |> Option.get)
3939+ ~date_modified:(Jsonfeed.parse_rfc3339 "2024-11-01T15:30:00Z" |> Option.get)
4040+ ~authors:[jane]
4141+ ~tags:["ocaml"; "programming"; "functional"]
4242+ ~summary:"A beginner's guide to OCaml programming"
4343+ () in
4444+4545+ let item2 = Item.create
4646+ ~id:"https://example.com/posts/2"
4747+ ~url:"https://example.com/posts/2"
4848+ ~title:"JSON Feed for Syndication"
4949+ ~content:(`Html "<p>JSON Feed is a modern alternative to RSS and Atom.</p>")
5050+ ~date_published:(Jsonfeed.parse_rfc3339 "2024-11-02T09:00:00Z" |> Option.get)
5151+ ~authors:[jane; john]
5252+ ~tags:["json"; "syndication"; "web"]
5353+ ~image:"https://example.com/images/jsonfeed.png"
5454+ () in
5555+5656+ (* Microblog-style item (text only, no title) *)
5757+ let item3 = Item.create
5858+ ~id:"https://example.com/micro/42"
5959+ ~content:(`Text "Just shipped a new feature! 🚀")
6060+ ~date_published:(Jsonfeed.parse_rfc3339 "2024-11-03T08:15:00Z" |> Option.get)
6161+ ~tags:["microblog"]
6262+ () in
6363+6464+ (* Create the complete feed *)
6565+ let feed = Jsonfeed.create
6666+ ~title:"Example Blog"
6767+ ~home_page_url:"https://example.com"
6868+ ~feed_url:"https://example.com/feed.json"
6969+ ~description:"A blog about programming, web development, and technology"
7070+ ~icon:"https://example.com/icon-512.png"
7171+ ~favicon:"https://example.com/favicon-64.png"
7272+ ~authors:[jane; john]
7373+ ~language:"en-US"
7474+ ~items:[item1; item2; item3]
7575+ () in
7676+7777+ feed
7878+7979+let create_podcast_feed () =
8080+ (* Create podcast author *)
8181+ let host = Author.create
8282+ ~name:"Podcast Host"
8383+ ~url:"https://podcast.example.com/host"
8484+ ~avatar:"https://podcast.example.com/host-avatar.jpg"
8585+ () in
8686+8787+ (* Create episode with audio attachment *)
8888+ let attachment = Attachment.create
8989+ ~url:"https://podcast.example.com/episodes/ep1.mp3"
9090+ ~mime_type:"audio/mpeg"
9191+ ~title:"Episode 1: Introduction"
9292+ ~size_in_bytes:15_728_640L
9393+ ~duration_in_seconds:1800
9494+ () in
9595+9696+ let episode = Item.create
9797+ ~id:"https://podcast.example.com/episodes/1"
9898+ ~url:"https://podcast.example.com/episodes/1"
9999+ ~title:"Episode 1: Introduction"
100100+ ~content:(`Html "<p>Welcome to our first episode!</p>")
101101+ ~date_published:(Jsonfeed.parse_rfc3339 "2024-11-01T12:00:00Z" |> Option.get)
102102+ ~attachments:[attachment]
103103+ ~authors:[host]
104104+ ~image:"https://podcast.example.com/episodes/ep1-cover.jpg"
105105+ () in
106106+107107+ (* Create podcast feed with hub for real-time updates *)
108108+ let hub = Hub.create
109109+ ~type_:"WebSub"
110110+ ~url:"https://pubsubhubbub.appspot.com/"
111111+ () in
112112+113113+ let feed = Jsonfeed.create
114114+ ~title:"Example Podcast"
115115+ ~home_page_url:"https://podcast.example.com"
116116+ ~feed_url:"https://podcast.example.com/feed.json"
117117+ ~description:"A podcast about interesting topics"
118118+ ~icon:"https://podcast.example.com/icon.png"
119119+ ~authors:[host]
120120+ ~language:"en-US"
121121+ ~hubs:[hub]
122122+ ~items:[episode]
123123+ () in
124124+125125+ feed
126126+127127+let main () =
128128+ Eio_main.run @@ fun env ->
129129+130130+ (* Create blog feed *)
131131+ let blog_feed = create_blog_feed () in
132132+ Format.printf "Created blog feed: %a\n\n" Jsonfeed.pp blog_feed;
133133+134134+ (* Serialize to string *)
135135+ let json_string = Jsonfeed.to_string blog_feed in
136136+ Format.printf "JSON (first 200 chars): %s...\n\n"
137137+ (String.sub json_string 0 (min 200 (String.length json_string)));
138138+139139+ (* Serialize to file *)
140140+ let feed_path = Eio.Path.(env#fs / "blog-feed.json") in
141141+ Eio.Path.with_open_out ~create:(`Or_truncate 0o644) feed_path @@ fun flow ->
142142+ to_flow (flow :> Eio.Flow.sink_ty Eio.Resource.t) blog_feed;
143143+ Format.printf "Wrote blog feed to blog-feed.json\n\n";
144144+145145+ (* Create podcast feed *)
146146+ let podcast_feed = create_podcast_feed () in
147147+ Format.printf "Created podcast feed: %a\n\n" Jsonfeed.pp_summary podcast_feed;
148148+149149+ (* Validate feeds *)
150150+ (match Jsonfeed.validate blog_feed with
151151+ | Ok () -> Format.printf "✓ Blog feed is valid\n"
152152+ | Error errors ->
153153+ Format.printf "✗ Blog feed validation errors:\n";
154154+ List.iter (Format.printf " - %s\n") errors);
155155+156156+ (match Jsonfeed.validate podcast_feed with
157157+ | Ok () -> Format.printf "✓ Podcast feed is valid\n"
158158+ | Error errors ->
159159+ Format.printf "✗ Podcast feed validation errors:\n";
160160+ List.iter (Format.printf " - %s\n") errors)
161161+162162+let () = main ()
+193
example/feed_parser.ml
···11+(** Example: Parsing and analyzing JSON Feeds
22+33+ This demonstrates:
44+ - Parsing feeds from files
55+ - Analyzing feed metadata
66+ - Iterating over items
77+ - Working with dates and content *)
88+99+open Jsonfeed
1010+1111+(* Helper to read feed from file *)
1212+let of_file filename =
1313+ let content = In_channel.with_open_text filename In_channel.input_all in
1414+ Jsonfeed.of_string content
1515+1616+let print_feed_info feed =
1717+ Format.printf "Feed Information:\n";
1818+ Format.printf " Title: %s\n" (Jsonfeed.title feed);
1919+ Format.printf " Version: %s\n" (Jsonfeed.version feed);
2020+2121+ (match Jsonfeed.home_page_url feed with
2222+ | Some url -> Format.printf " Home Page: %s\n" url
2323+ | None -> ());
2424+2525+ (match Jsonfeed.feed_url feed with
2626+ | Some url -> Format.printf " Feed URL: %s\n" url
2727+ | None -> ());
2828+2929+ (match Jsonfeed.description feed with
3030+ | Some desc -> Format.printf " Description: %s\n" desc
3131+ | None -> ());
3232+3333+ (match Jsonfeed.language feed with
3434+ | Some lang -> Format.printf " Language: %s\n" lang
3535+ | None -> ());
3636+3737+ (match Jsonfeed.authors feed with
3838+ | Some authors ->
3939+ Format.printf " Authors:\n";
4040+ List.iter (fun author ->
4141+ match Author.name author with
4242+ | Some name -> Format.printf " - %s" name;
4343+ (match Author.url author with
4444+ | Some url -> Format.printf " (%s)" url
4545+ | None -> ());
4646+ Format.printf "\n"
4747+ | None -> ()
4848+ ) authors
4949+ | None -> ());
5050+5151+ Format.printf " Items: %d\n\n" (List.length (Jsonfeed.items feed))
5252+5353+let print_item_details item =
5454+ Format.printf "Item: %s\n" (Item.id item);
5555+5656+ (match Item.title item with
5757+ | Some title -> Format.printf " Title: %s\n" title
5858+ | None -> Format.printf " (No title - microblog entry)\n");
5959+6060+ (match Item.url item with
6161+ | Some url -> Format.printf " URL: %s\n" url
6262+ | None -> ());
6363+6464+ (* Print content info *)
6565+ (match Item.content item with
6666+ | `Html html ->
6767+ Format.printf " Content: HTML only (%d chars)\n"
6868+ (String.length html)
6969+ | `Text text ->
7070+ Format.printf " Content: Text only (%d chars)\n"
7171+ (String.length text)
7272+ | `Both (html, text) ->
7373+ Format.printf " Content: Both HTML (%d chars) and Text (%d chars)\n"
7474+ (String.length html) (String.length text));
7575+7676+ (* Print dates *)
7777+ (match Item.date_published item with
7878+ | Some date ->
7979+ Format.printf " Published: %s\n"
8080+ (Jsonfeed.format_rfc3339 date)
8181+ | None -> ());
8282+8383+ (match Item.date_modified item with
8484+ | Some date ->
8585+ Format.printf " Modified: %s\n"
8686+ (Jsonfeed.format_rfc3339 date)
8787+ | None -> ());
8888+8989+ (* Print tags *)
9090+ (match Item.tags item with
9191+ | Some tags when tags <> [] ->
9292+ Format.printf " Tags: %s\n" (String.concat ", " tags)
9393+ | _ -> ());
9494+9595+ (* Print attachments *)
9696+ (match Item.attachments item with
9797+ | Some attachments when attachments <> [] ->
9898+ Format.printf " Attachments:\n";
9999+ List.iter (fun att ->
100100+ Format.printf " - %s (%s)\n"
101101+ (Attachment.url att)
102102+ (Attachment.mime_type att);
103103+ (match Attachment.size_in_bytes att with
104104+ | Some size ->
105105+ let mb = Int64.to_float size /. (1024. *. 1024.) in
106106+ Format.printf " Size: %.2f MB\n" mb
107107+ | None -> ());
108108+ (match Attachment.duration_in_seconds att with
109109+ | Some duration ->
110110+ let mins = duration / 60 in
111111+ let secs = duration mod 60 in
112112+ Format.printf " Duration: %dm%ds\n" mins secs
113113+ | None -> ())
114114+ ) attachments
115115+ | _ -> ());
116116+117117+ Format.printf "\n"
118118+119119+let analyze_feed feed =
120120+ let items = Jsonfeed.items feed in
121121+122122+ Format.printf "\n=== Feed Analysis ===\n\n";
123123+124124+ (* Count content types *)
125125+ let html_only = ref 0 in
126126+ let text_only = ref 0 in
127127+ let both = ref 0 in
128128+129129+ List.iter (fun item ->
130130+ match Item.content item with
131131+ | `Html _ -> incr html_only
132132+ | `Text _ -> incr text_only
133133+ | `Both _ -> incr both
134134+ ) items;
135135+136136+ Format.printf "Content Types:\n";
137137+ Format.printf " HTML only: %d\n" !html_only;
138138+ Format.printf " Text only: %d\n" !text_only;
139139+ Format.printf " Both: %d\n\n" !both;
140140+141141+ (* Find items with attachments *)
142142+ let with_attachments = List.filter (fun item ->
143143+ match Item.attachments item with
144144+ | Some att when att <> [] -> true
145145+ | _ -> false
146146+ ) items in
147147+148148+ Format.printf "Items with attachments: %d\n\n" (List.length with_attachments);
149149+150150+ (* Collect all unique tags *)
151151+ let all_tags = List.fold_left (fun acc item ->
152152+ match Item.tags item with
153153+ | Some tags -> acc @ tags
154154+ | None -> acc
155155+ ) [] items in
156156+ let unique_tags = List.sort_uniq String.compare all_tags in
157157+158158+ if unique_tags <> [] then (
159159+ Format.printf "All tags used: %s\n\n" (String.concat ", " unique_tags)
160160+ )
161161+162162+let main () =
163163+ (* Parse from example_feed.json file *)
164164+ Format.printf "=== Parsing JSON Feed from example_feed.json ===\n\n";
165165+166166+ (try
167167+ match of_file "example/example_feed.json" with
168168+ | Ok feed ->
169169+ print_feed_info feed;
170170+171171+ Format.printf "=== Items ===\n\n";
172172+ List.iter print_item_details (Jsonfeed.items feed);
173173+174174+ analyze_feed feed;
175175+176176+ (* Demonstrate round-trip parsing *)
177177+ Format.printf "\n=== Round-trip Test ===\n\n";
178178+ let json = Jsonfeed.to_string feed in
179179+ (match Jsonfeed.of_string json with
180180+ | Ok feed2 ->
181181+ if Jsonfeed.equal feed feed2 then
182182+ Format.printf "✓ Round-trip successful: feeds are equal\n"
183183+ else
184184+ Format.printf "✗ Round-trip failed: feeds differ\n"
185185+ | Error (`Msg err) ->
186186+ Format.eprintf "✗ Round-trip failed: %s\n" err)
187187+ | Error (`Msg err) ->
188188+ Format.eprintf "Error parsing feed: %s\n" err
189189+ with
190190+ | Sys_error msg ->
191191+ Format.eprintf "Error reading file: %s\n" msg)
192192+193193+let () = main ()
+303
example/feed_validator.ml
···11+(** Example: Validating JSON Feeds
22+33+ This demonstrates:
44+ - Validating feed structure
55+ - Testing various edge cases
66+ - Handling invalid feeds
77+ - Best practices for feed construction *)
88+99+open Jsonfeed
1010+1111+let test_valid_minimal_feed () =
1212+ Format.printf "=== Test: Minimal Valid Feed ===\n";
1313+1414+ let feed = Jsonfeed.create
1515+ ~title:"Minimal Feed"
1616+ ~items:[]
1717+ () in
1818+1919+ match Jsonfeed.validate feed with
2020+ | Ok () -> Format.printf "✓ Minimal feed is valid\n\n"
2121+ | Error errors ->
2222+ Format.printf "✗ Minimal feed validation failed:\n";
2323+ List.iter (Format.printf " - %s\n") errors;
2424+ Format.printf "\n"
2525+2626+let test_valid_complete_feed () =
2727+ Format.printf "=== Test: Complete Valid Feed ===\n";
2828+2929+ let author = Author.create
3030+ ~name:"Test Author"
3131+ ~url:"https://example.com/author"
3232+ ~avatar:"https://example.com/avatar.png"
3333+ () in
3434+3535+ let attachment = Attachment.create
3636+ ~url:"https://example.com/file.mp3"
3737+ ~mime_type:"audio/mpeg"
3838+ ~title:"Audio File"
3939+ ~size_in_bytes:1024L
4040+ ~duration_in_seconds:60
4141+ () in
4242+4343+ let item = Item.create
4444+ ~id:"https://example.com/items/1"
4545+ ~url:"https://example.com/items/1"
4646+ ~title:"Test Item"
4747+ ~content:(`Both ("<p>HTML content</p>", "Text content"))
4848+ ~summary:"A test item"
4949+ ~image:"https://example.com/image.jpg"
5050+ ~banner_image:"https://example.com/banner.jpg"
5151+ ~date_published:(Jsonfeed.parse_rfc3339 "2024-11-01T10:00:00Z" |> Option.get)
5252+ ~date_modified:(Jsonfeed.parse_rfc3339 "2024-11-01T15:00:00Z" |> Option.get)
5353+ ~authors:[author]
5454+ ~tags:["test"; "example"]
5555+ ~language:"en"
5656+ ~attachments:[attachment]
5757+ () in
5858+5959+ let hub = Hub.create
6060+ ~type_:"WebSub"
6161+ ~url:"https://pubsubhubbub.appspot.com/"
6262+ () in
6363+6464+ let feed = Jsonfeed.create
6565+ ~title:"Complete Feed"
6666+ ~home_page_url:"https://example.com"
6767+ ~feed_url:"https://example.com/feed.json"
6868+ ~description:"A complete test feed"
6969+ ~user_comment:"This is a test feed"
7070+ ~icon:"https://example.com/icon.png"
7171+ ~favicon:"https://example.com/favicon.ico"
7272+ ~authors:[author]
7373+ ~language:"en-US"
7474+ ~hubs:[hub]
7575+ ~items:[item]
7676+ () in
7777+7878+ match Jsonfeed.validate feed with
7979+ | Ok () -> Format.printf "✓ Complete feed is valid\n\n"
8080+ | Error errors ->
8181+ Format.printf "✗ Complete feed validation failed:\n";
8282+ List.iter (Format.printf " - %s\n") errors;
8383+ Format.printf "\n"
8484+8585+let test_feed_with_multiple_items () =
8686+ Format.printf "=== Test: Feed with Multiple Items ===\n";
8787+8888+ let items = List.init 10 (fun i ->
8989+ Item.create
9090+ ~id:(Printf.sprintf "https://example.com/items/%d" i)
9191+ ~content:(`Text (Printf.sprintf "Item %d content" i))
9292+ ~title:(Printf.sprintf "Item %d" i)
9393+ ~date_published:(Jsonfeed.parse_rfc3339
9494+ (Printf.sprintf "2024-11-%02dT10:00:00Z" (i + 1)) |> Option.get)
9595+ ()
9696+ ) in
9797+9898+ let feed = Jsonfeed.create
9999+ ~title:"Multi-item Feed"
100100+ ~items
101101+ () in
102102+103103+ match Jsonfeed.validate feed with
104104+ | Ok () ->
105105+ Format.printf "✓ Feed with %d items is valid\n\n" (List.length items)
106106+ | Error errors ->
107107+ Format.printf "✗ Multi-item feed validation failed:\n";
108108+ List.iter (Format.printf " - %s\n") errors;
109109+ Format.printf "\n"
110110+111111+let test_podcast_feed () =
112112+ Format.printf "=== Test: Podcast Feed ===\n";
113113+114114+ let host = Author.create
115115+ ~name:"Podcast Host"
116116+ ~url:"https://podcast.example.com/host"
117117+ () in
118118+119119+ let episode1 = Attachment.create
120120+ ~url:"https://podcast.example.com/ep1.mp3"
121121+ ~mime_type:"audio/mpeg"
122122+ ~title:"Episode 1"
123123+ ~size_in_bytes:20_971_520L (* 20 MB *)
124124+ ~duration_in_seconds:1800 (* 30 minutes *)
125125+ () in
126126+127127+ (* Alternate format of the same episode *)
128128+ let episode1_aac = Attachment.create
129129+ ~url:"https://podcast.example.com/ep1.aac"
130130+ ~mime_type:"audio/aac"
131131+ ~title:"Episode 1"
132132+ ~size_in_bytes:16_777_216L
133133+ ~duration_in_seconds:1800
134134+ () in
135135+136136+ let item = Item.create
137137+ ~id:"https://podcast.example.com/episodes/1"
138138+ ~url:"https://podcast.example.com/episodes/1"
139139+ ~title:"Episode 1: Introduction"
140140+ ~content:(`Html "<p>Welcome to the first episode!</p>")
141141+ ~date_published:(Jsonfeed.parse_rfc3339 "2024-11-01T12:00:00Z" |> Option.get)
142142+ ~authors:[host]
143143+ ~attachments:[episode1; episode1_aac]
144144+ ~image:"https://podcast.example.com/ep1-cover.jpg"
145145+ () in
146146+147147+ let feed = Jsonfeed.create
148148+ ~title:"Example Podcast"
149149+ ~home_page_url:"https://podcast.example.com"
150150+ ~feed_url:"https://podcast.example.com/feed.json"
151151+ ~authors:[host]
152152+ ~items:[item]
153153+ () in
154154+155155+ match Jsonfeed.validate feed with
156156+ | Ok () -> Format.printf "✓ Podcast feed is valid\n\n"
157157+ | Error errors ->
158158+ Format.printf "✗ Podcast feed validation failed:\n";
159159+ List.iter (Format.printf " - %s\n") errors;
160160+ Format.printf "\n"
161161+162162+let test_microblog_feed () =
163163+ Format.printf "=== Test: Microblog Feed (no titles) ===\n";
164164+165165+ let author = Author.create
166166+ ~name:"Microblogger"
167167+ ~url:"https://micro.example.com"
168168+ () in
169169+170170+ let items = [
171171+ Item.create
172172+ ~id:"https://micro.example.com/1"
173173+ ~content:(`Text "Just posted a new photo!")
174174+ ~date_published:(Jsonfeed.parse_rfc3339 "2024-11-01T08:00:00Z" |> Option.get)
175175+ ();
176176+ Item.create
177177+ ~id:"https://micro.example.com/2"
178178+ ~content:(`Text "Having a great day! ☀️")
179179+ ~date_published:(Jsonfeed.parse_rfc3339 "2024-11-01T12:30:00Z" |> Option.get)
180180+ ();
181181+ Item.create
182182+ ~id:"https://micro.example.com/3"
183183+ ~content:(`Html "<p>Check out this <a href=\"#\">link</a></p>")
184184+ ~date_published:(Jsonfeed.parse_rfc3339 "2024-11-01T16:45:00Z" |> Option.get)
185185+ ()
186186+ ] in
187187+188188+ let feed = Jsonfeed.create
189189+ ~title:"Microblog"
190190+ ~home_page_url:"https://micro.example.com"
191191+ ~authors:[author]
192192+ ~items
193193+ () in
194194+195195+ match Jsonfeed.validate feed with
196196+ | Ok () ->
197197+ Format.printf "✓ Microblog feed with %d items is valid\n\n"
198198+ (List.length items)
199199+ | Error errors ->
200200+ Format.printf "✗ Microblog feed validation failed:\n";
201201+ List.iter (Format.printf " - %s\n") errors;
202202+ Format.printf "\n"
203203+204204+let test_expired_feed () =
205205+ Format.printf "=== Test: Expired Feed ===\n";
206206+207207+ let feed = Jsonfeed.create
208208+ ~title:"Archived Blog"
209209+ ~home_page_url:"https://archive.example.com"
210210+ ~description:"This blog is no longer updated"
211211+ ~expired:true
212212+ ~items:[]
213213+ () in
214214+215215+ match Jsonfeed.validate feed with
216216+ | Ok () -> Format.printf "✓ Expired feed is valid\n\n"
217217+ | Error errors ->
218218+ Format.printf "✗ Expired feed validation failed:\n";
219219+ List.iter (Format.printf " - %s\n") errors;
220220+ Format.printf "\n"
221221+222222+let test_paginated_feed () =
223223+ Format.printf "=== Test: Paginated Feed ===\n";
224224+225225+ let items = List.init 25 (fun i ->
226226+ Item.create
227227+ ~id:(Printf.sprintf "https://example.com/items/%d" i)
228228+ ~content:(`Text (Printf.sprintf "Item %d" i))
229229+ ()
230230+ ) in
231231+232232+ let feed = Jsonfeed.create
233233+ ~title:"Large Feed"
234234+ ~home_page_url:"https://example.com"
235235+ ~feed_url:"https://example.com/feed.json?page=1"
236236+ ~next_url:"https://example.com/feed.json?page=2"
237237+ ~items
238238+ () in
239239+240240+ match Jsonfeed.validate feed with
241241+ | Ok () ->
242242+ Format.printf "✓ Paginated feed is valid (page 1 with next_url)\n\n"
243243+ | Error errors ->
244244+ Format.printf "✗ Paginated feed validation failed:\n";
245245+ List.iter (Format.printf " - %s\n") errors;
246246+ Format.printf "\n"
247247+248248+let test_invalid_feed_from_json () =
249249+ Format.printf "=== Test: Parsing Invalid JSON ===\n";
250250+251251+ (* Missing required version field *)
252252+ let invalid_json1 = {|{
253253+ "title": "Test",
254254+ "items": []
255255+ }|} in
256256+257257+ (match Jsonfeed.of_string invalid_json1 with
258258+ | Ok _ -> Format.printf "✗ Should have failed (missing version)\n"
259259+ | Error (`Msg err) ->
260260+ Format.printf "✓ Correctly rejected invalid feed: %s\n" err);
261261+262262+ (* Missing required title field *)
263263+ let invalid_json2 = {|{
264264+ "version": "https://jsonfeed.org/version/1.1",
265265+ "items": []
266266+ }|} in
267267+268268+ (match Jsonfeed.of_string invalid_json2 with
269269+ | Ok _ -> Format.printf "✗ Should have failed (missing title)\n"
270270+ | Error (`Msg err) ->
271271+ Format.printf "✓ Correctly rejected invalid feed: %s\n" err);
272272+273273+ (* Item without id *)
274274+ let invalid_json3 = {|{
275275+ "version": "https://jsonfeed.org/version/1.1",
276276+ "title": "Test",
277277+ "items": [{
278278+ "content_text": "Hello"
279279+ }]
280280+ }|} in
281281+282282+ (match Jsonfeed.of_string invalid_json3 with
283283+ | Ok _ -> Format.printf "✗ Should have failed (item without id)\n"
284284+ | Error (`Msg err) ->
285285+ Format.printf "✓ Correctly rejected invalid feed: %s\n" err);
286286+287287+ Format.printf "\n"
288288+289289+let main () =
290290+ Format.printf "\n=== JSON Feed Validation Tests ===\n\n";
291291+292292+ test_valid_minimal_feed ();
293293+ test_valid_complete_feed ();
294294+ test_feed_with_multiple_items ();
295295+ test_podcast_feed ();
296296+ test_microblog_feed ();
297297+ test_expired_feed ();
298298+ test_paginated_feed ();
299299+ test_invalid_feed_from_json ();
300300+301301+ Format.printf "=== All Tests Complete ===\n"
302302+303303+let () = main ()
+34
jsonfeed.opam
···11+# This file is generated by dune, edit dune-project instead
22+opam-version: "2.0"
33+synopsis: "JSON Feed format parser and serializer for OCaml"
44+description:
55+ "This library implements the JSON Feed specification (version 1.1) for OCaml. JSON Feed is a syndication format similar to RSS and Atom, but using JSON instead of XML. The library provides type-safe parsing and serialization using Jsonm and Ptime."
66+maintainer: ["Anil Madhavapeddy <anil@recoil.org>"]
77+authors: ["Anil Madhavapeddy"]
88+license: "ISC"
99+homepage: "https://tangled.sh/@anil.recoil.org/ocaml-jsonfeed"
1010+bug-reports: "https://tangled.sh/@anil.recoil.org/ocaml-jsonfeed"
1111+depends: [
1212+ "dune" {>= "3.20"}
1313+ "ocaml" {>= "5.2.0"}
1414+ "jsonm" {>= "1.0.0"}
1515+ "ptime" {>= "1.2.0"}
1616+ "fmt" {>= "0.11.0"}
1717+ "odoc" {with-doc}
1818+ "alcotest" {with-test & >= "1.9.0"}
1919+]
2020+build: [
2121+ ["dune" "subst"] {dev}
2222+ [
2323+ "dune"
2424+ "build"
2525+ "-p"
2626+ name
2727+ "-j"
2828+ jobs
2929+ "@install"
3030+ "@runtest" {with-test}
3131+ "@doc" {with-doc}
3232+ ]
3333+]
3434+x-maintenance-intent: ["(latest)"]
+51
lib/attachment.ml
···11+(** Attachments for JSON Feed items. *)
22+33+type t = {
44+ url : string;
55+ mime_type : string;
66+ title : string option;
77+ size_in_bytes : int64 option;
88+ duration_in_seconds : int option;
99+}
1010+1111+let create ~url ~mime_type ?title ?size_in_bytes ?duration_in_seconds () =
1212+ { url; mime_type; title; size_in_bytes; duration_in_seconds }
1313+1414+let url t = t.url
1515+let mime_type t = t.mime_type
1616+let title t = t.title
1717+let size_in_bytes t = t.size_in_bytes
1818+let duration_in_seconds t = t.duration_in_seconds
1919+2020+let equal a b =
2121+ a.url = b.url &&
2222+ a.mime_type = b.mime_type &&
2323+ a.title = b.title &&
2424+ a.size_in_bytes = b.size_in_bytes &&
2525+ a.duration_in_seconds = b.duration_in_seconds
2626+2727+let pp ppf t =
2828+ (* Extract filename from URL *)
2929+ let filename =
3030+ try
3131+ let parts = String.split_on_char '/' t.url in
3232+ List.nth parts (List.length parts - 1)
3333+ with _ -> t.url
3434+ in
3535+3636+ Format.fprintf ppf "%s (%s" filename t.mime_type;
3737+3838+ (match t.size_in_bytes with
3939+ | Some size ->
4040+ let mb = Int64.to_float size /. (1024. *. 1024.) in
4141+ Format.fprintf ppf ", %.1f MB" mb
4242+ | None -> ());
4343+4444+ (match t.duration_in_seconds with
4545+ | Some duration ->
4646+ let mins = duration / 60 in
4747+ let secs = duration mod 60 in
4848+ Format.fprintf ppf ", %dm%ds" mins secs
4949+ | None -> ());
5050+5151+ Format.fprintf ppf ")"
+91
lib/attachment.mli
···11+(** Attachments for JSON Feed items.
22+33+ An attachment represents an external resource related to a feed item,
44+ such as audio files for podcasts, video files, or other downloadable content.
55+ Attachments with identical titles indicate alternate formats of the same resource.
66+77+ @see <https://www.jsonfeed.org/version/1.1/> JSON Feed Specification *)
88+99+1010+(** The type representing an attachment. *)
1111+type t
1212+1313+1414+(** {1 Construction} *)
1515+1616+(** [create ~url ~mime_type ?title ?size_in_bytes ?duration_in_seconds ()]
1717+ creates an attachment object.
1818+1919+ @param url The location of the attachment (required)
2020+ @param mime_type The MIME type of the attachment, e.g. ["audio/mpeg"] (required)
2121+ @param title The name of the attachment; identical titles indicate alternate formats
2222+ of the same resource
2323+ @param size_in_bytes The size of the attachment file in bytes
2424+ @param duration_in_seconds The duration of the attachment in seconds (for audio/video)
2525+2626+ {b Examples:}
2727+ {[
2828+ (* Simple attachment *)
2929+ let att = Attachment.create
3030+ ~url:"https://example.com/episode.mp3"
3131+ ~mime_type:"audio/mpeg" ()
3232+3333+ (* Podcast episode with metadata *)
3434+ let att = Attachment.create
3535+ ~url:"https://example.com/episode.mp3"
3636+ ~mime_type:"audio/mpeg"
3737+ ~title:"Episode 42"
3838+ ~size_in_bytes:15_728_640L
3939+ ~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 ()
4848+ ]} *)
4949+val create :
5050+ url:string ->
5151+ mime_type:string ->
5252+ ?title:string ->
5353+ ?size_in_bytes:int64 ->
5454+ ?duration_in_seconds:int ->
5555+ unit ->
5656+ t
5757+5858+5959+(** {1 Accessors} *)
6060+6161+(** [url t] returns the attachment's URL. *)
6262+val url : t -> string
6363+6464+(** [mime_type t] returns the attachment's MIME type. *)
6565+val mime_type : t -> string
6666+6767+(** [title t] returns the attachment's title, if set. *)
6868+val title : t -> string option
6969+7070+(** [size_in_bytes t] returns the attachment's size in bytes, if set. *)
7171+val size_in_bytes : t -> int64 option
7272+7373+(** [duration_in_seconds t] returns the attachment's duration, if set. *)
7474+val duration_in_seconds : t -> int option
7575+7676+7777+(** {1 Comparison} *)
7878+7979+(** [equal a b] tests equality between two attachments. *)
8080+val equal : t -> t -> bool
8181+8282+8383+(** {1 Pretty Printing} *)
8484+8585+(** [pp ppf t] pretty prints an attachment to the formatter.
8686+8787+ The output is human-readable and suitable for debugging.
8888+8989+ {b Example output:}
9090+ {v episode.mp3 (audio/mpeg, 15.0 MB, 30m0s) v} *)
9191+val pp : Format.formatter -> t -> unit
+32
lib/author.ml
···11+(** Author information for JSON Feed items and feeds. *)
22+33+type t = {
44+ name : string option;
55+ url : string option;
66+ avatar : string option;
77+}
88+99+let create ?name ?url ?avatar () =
1010+ if name = None && url = None && avatar = None then
1111+ invalid_arg "Author.create: at least one field (name, url, or avatar) must be provided";
1212+ { name; url; avatar }
1313+1414+let name t = t.name
1515+let url t = t.url
1616+let avatar t = t.avatar
1717+1818+let is_valid t =
1919+ t.name <> None || t.url <> None || t.avatar <> None
2020+2121+let equal a b =
2222+ a.name = b.name && a.url = b.url && a.avatar = b.avatar
2323+2424+let pp ppf t =
2525+ match t.name, t.url with
2626+ | Some name, Some url -> Format.fprintf ppf "%s <%s>" name url
2727+ | Some name, None -> Format.fprintf ppf "%s" name
2828+ | None, Some url -> Format.fprintf ppf "<%s>" url
2929+ | None, None ->
3030+ match t.avatar with
3131+ | Some avatar -> Format.fprintf ppf "(avatar: %s)" avatar
3232+ | None -> Format.fprintf ppf "(empty author)"
+72
lib/author.mli
···11+(** Author information for JSON Feed items and feeds.
22+33+ An author object provides information about the creator of a feed or item.
44+ According to the JSON Feed 1.1 specification, at least one field must be
55+ present when an author object is included.
66+77+ @see <https://www.jsonfeed.org/version/1.1/> JSON Feed Specification *)
88+99+1010+(** The type representing an author. *)
1111+type t
1212+1313+1414+(** {1 Construction} *)
1515+1616+(** [create ?name ?url ?avatar ()] creates an author object.
1717+1818+ At least one of the optional parameters must be provided, otherwise
1919+ the function will raise [Invalid_argument].
2020+2121+ @param name The author's name
2222+ @param url URL of the author's website or profile
2323+ @param avatar URL of the author's avatar image (should be square, 512x512 or larger)
2424+2525+ {b Examples:}
2626+ {[
2727+ let author = Author.create ~name:"Jane Doe" ()
2828+ let author = Author.create ~name:"Jane Doe" ~url:"https://janedoe.com" ()
2929+ let author = Author.create
3030+ ~name:"Jane Doe"
3131+ ~url:"https://janedoe.com"
3232+ ~avatar:"https://janedoe.com/avatar.png" ()
3333+ ]} *)
3434+val create : ?name:string -> ?url:string -> ?avatar:string -> unit -> t
3535+3636+3737+(** {1 Accessors} *)
3838+3939+(** [name t] returns the author's name, if set. *)
4040+val name : t -> string option
4141+4242+(** [url t] returns the author's URL, if set. *)
4343+val url : t -> string option
4444+4545+(** [avatar t] returns the author's avatar URL, if set. *)
4646+val avatar : t -> string option
4747+4848+4949+(** {1 Predicates} *)
5050+5151+(** [is_valid t] checks if the author has at least one field set.
5252+5353+ This should always return [true] for authors created via {!create},
5454+ but may be useful when parsing from external sources. *)
5555+val is_valid : t -> bool
5656+5757+5858+(** {1 Comparison} *)
5959+6060+(** [equal a b] tests equality between two authors. *)
6161+val equal : t -> t -> bool
6262+6363+6464+(** {1 Pretty Printing} *)
6565+6666+(** [pp ppf t] pretty prints an author to the formatter.
6767+6868+ The output is human-readable and suitable for debugging.
6969+7070+ {b Example output:}
7171+ {v Jane Doe <https://janedoe.com> v} *)
7272+val pp : Format.formatter -> t -> unit
···11+(** Hub endpoints for real-time notifications. *)
22+33+type t = {
44+ type_ : string;
55+ url : string;
66+}
77+88+let create ~type_ ~url () =
99+ { type_; url }
1010+1111+let type_ t = t.type_
1212+let url t = t.url
1313+1414+let equal a b =
1515+ a.type_ = b.type_ && a.url = b.url
1616+1717+let pp ppf t =
1818+ Format.fprintf ppf "%s: %s" t.type_ t.url
+53
lib/hub.mli
···11+(** Hub endpoints for real-time notifications.
22+33+ Hubs describe endpoints that can be used to subscribe to real-time
44+ notifications of changes to the feed. This is an optional and rarely-used
55+ feature of JSON Feed, primarily for feeds that update frequently.
66+77+ @see <https://www.jsonfeed.org/version/1.1/> JSON Feed Specification *)
88+99+1010+(** The type representing a hub endpoint. *)
1111+type t
1212+1313+1414+(** {1 Construction} *)
1515+1616+(** [create ~type_ ~url ()] creates a hub object.
1717+1818+ @param type_ The type of hub protocol (e.g., ["rssCloud"], ["WebSub"])
1919+ @param url The URL endpoint for the hub
2020+2121+ {b Example:}
2222+ {[
2323+ let hub = Hub.create
2424+ ~type_:"WebSub"
2525+ ~url:"https://pubsubhubbub.appspot.com/" ()
2626+ ]} *)
2727+val create : type_:string -> url:string -> unit -> t
2828+2929+3030+(** {1 Accessors} *)
3131+3232+(** [type_ t] returns the hub's protocol type. *)
3333+val type_ : t -> string
3434+3535+(** [url t] returns the hub's endpoint URL. *)
3636+val url : t -> string
3737+3838+3939+(** {1 Comparison} *)
4040+4141+(** [equal a b] tests equality between two hubs. *)
4242+val equal : t -> t -> bool
4343+4444+4545+(** {1 Pretty Printing} *)
4646+4747+(** [pp ppf t] pretty prints a hub to the formatter.
4848+4949+ The output is human-readable and suitable for debugging.
5050+5151+ {b Example output:}
5252+ {v WebSub: https://pubsubhubbub.appspot.com/ v} *)
5353+val pp : Format.formatter -> t -> unit
+105
lib/item.ml
···11+(** Feed items in a JSON Feed. *)
22+33+type content =
44+ [ `Html of string
55+ | `Text of string
66+ | `Both of string * string
77+ ]
88+99+type t = {
1010+ id : string;
1111+ content : content;
1212+ url : string option;
1313+ external_url : string option;
1414+ title : string option;
1515+ summary : string option;
1616+ image : string option;
1717+ banner_image : string option;
1818+ date_published : Ptime.t option;
1919+ date_modified : Ptime.t option;
2020+ authors : Author.t list option;
2121+ tags : string list option;
2222+ language : string option;
2323+ attachments : Attachment.t list option;
2424+}
2525+2626+let create ~id ~content ?url ?external_url ?title ?summary ?image ?banner_image
2727+ ?date_published ?date_modified ?authors ?tags ?language ?attachments () =
2828+ {
2929+ id;
3030+ content;
3131+ url;
3232+ external_url;
3333+ title;
3434+ summary;
3535+ image;
3636+ banner_image;
3737+ date_published;
3838+ date_modified;
3939+ authors;
4040+ tags;
4141+ language;
4242+ attachments;
4343+ }
4444+4545+let id t = t.id
4646+let content t = t.content
4747+let url t = t.url
4848+let external_url t = t.external_url
4949+let title t = t.title
5050+let summary t = t.summary
5151+let image t = t.image
5252+let banner_image t = t.banner_image
5353+let date_published t = t.date_published
5454+let date_modified t = t.date_modified
5555+let authors t = t.authors
5656+let tags t = t.tags
5757+let language t = t.language
5858+let attachments t = t.attachments
5959+6060+let content_html t =
6161+ match t.content with
6262+ | `Html html -> Some html
6363+ | `Text _ -> None
6464+ | `Both (html, _) -> Some html
6565+6666+let content_text t =
6767+ match t.content with
6868+ | `Html _ -> None
6969+ | `Text text -> Some text
7070+ | `Both (_, text) -> Some text
7171+7272+let equal a b =
7373+ (* Items are equal if they have the same ID *)
7474+ a.id = b.id
7575+7676+let compare a b =
7777+ (* Compare by publication date, with items without dates considered older *)
7878+ match a.date_published, b.date_published with
7979+ | None, None -> 0
8080+ | None, Some _ -> -1 (* Items without dates are "older" *)
8181+ | Some _, None -> 1
8282+ | Some da, Some db -> Ptime.compare da db
8383+8484+let pp_content ppf = function
8585+ | `Html html ->
8686+ Format.fprintf ppf "HTML (%d chars)" (String.length html)
8787+ | `Text text ->
8888+ Format.fprintf ppf "Text (%d chars)" (String.length text)
8989+ | `Both (html, text) ->
9090+ Format.fprintf ppf "Both (HTML: %d chars, Text: %d chars)"
9191+ (String.length html) (String.length text)
9292+9393+let pp ppf t =
9494+ match t.date_published, t.title with
9595+ | Some date, Some title ->
9696+ (* Use Ptime's date formatting *)
9797+ let (y, m, d), _ = Ptime.to_date_time date in
9898+ Format.fprintf ppf "[%04d-%02d-%02d] %s (%s)" y m d title t.id
9999+ | Some date, None ->
100100+ let (y, m, d), _ = Ptime.to_date_time date in
101101+ Format.fprintf ppf "[%04d-%02d-%02d] %s" y m d t.id
102102+ | None, Some title ->
103103+ Format.fprintf ppf "%s (%s)" title t.id
104104+ | None, None ->
105105+ Format.fprintf ppf "%s" t.id
+190
lib/item.mli
···11+(** Feed items in a JSON Feed.
22+33+ An item represents a single entry in a feed, such as a blog post, podcast episode,
44+ or microblog entry. Each item must have a unique identifier and content.
55+66+ @see <https://www.jsonfeed.org/version/1.1/> JSON Feed Specification *)
77+88+99+(** The type representing a feed item. *)
1010+type t
1111+1212+(** Content representation for an item.
1313+1414+ The JSON Feed specification requires that each item has at least one
1515+ form of content. This type enforces that requirement at compile time.
1616+1717+ - [`Html s]: Item has HTML content only
1818+ - [`Text s]: Item has plain text content only
1919+ - [`Both (html, text)]: Item has both HTML and plain text versions *)
2020+type content =
2121+ [ `Html of string
2222+ | `Text of string
2323+ | `Both of string * string
2424+ ]
2525+2626+2727+(** {1 Construction} *)
2828+2929+(** [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.
3232+3333+ @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+4848+ {b Examples:}
4949+ {[
5050+ (* Simple blog post *)
5151+ let item = Item.create
5252+ ~id:"https://example.com/posts/42"
5353+ ~content:(`Html "<p>Hello, world!</p>")
5454+ ~title:"My First Post"
5555+ ~url:"https://example.com/posts/42" ()
5656+5757+ (* Microblog entry with plain text *)
5858+ let item = Item.create
5959+ ~id:"https://example.com/micro/123"
6060+ ~content:(`Text "Just posted a new photo!")
6161+ ~date_published:(Ptime.of_float_s (Unix.time ()) |> Option.get) ()
6262+6363+ (* Article with both HTML and plain text *)
6464+ let item = Item.create
6565+ ~id:"https://example.com/article/99"
6666+ ~content:(`Both ("<p>Rich content</p>", "Plain version"))
6767+ ~title:"Article Title"
6868+ ~tags:["ocaml"; "programming"] ()
6969+7070+ (* Podcast episode with attachment *)
7171+ let attachment = Attachment.create
7272+ ~url:"https://example.com/ep1.mp3"
7373+ ~mime_type:"audio/mpeg"
7474+ ~duration_in_seconds:1800 () in
7575+ let item = Item.create
7676+ ~id:"https://example.com/podcast/1"
7777+ ~content:(`Html "<p>Episode description</p>")
7878+ ~title:"Episode 1"
7979+ ~attachments:[attachment] ()
8080+ ]} *)
8181+val create :
8282+ id:string ->
8383+ content:content ->
8484+ ?url:string ->
8585+ ?external_url:string ->
8686+ ?title:string ->
8787+ ?summary:string ->
8888+ ?image:string ->
8989+ ?banner_image:string ->
9090+ ?date_published:Ptime.t ->
9191+ ?date_modified:Ptime.t ->
9292+ ?authors:Author.t list ->
9393+ ?tags:string list ->
9494+ ?language:string ->
9595+ ?attachments:Attachment.t list ->
9696+ unit ->
9797+ t
9898+9999+100100+(** {1 Accessors} *)
101101+102102+(** [id t] returns the item's unique identifier. *)
103103+val id : t -> string
104104+105105+(** [content t] returns the item's content. *)
106106+val content : t -> content
107107+108108+(** [url t] returns the item's permalink URL, if set. *)
109109+val url : t -> string option
110110+111111+(** [external_url t] returns the external resource URL, if set. *)
112112+val external_url : t -> string option
113113+114114+(** [title t] returns the item's title, if set. *)
115115+val title : t -> string option
116116+117117+(** [summary t] returns the item's summary, if set. *)
118118+val summary : t -> string option
119119+120120+(** [image t] returns the item's featured image URL, if set. *)
121121+val image : t -> string option
122122+123123+(** [banner_image t] returns the item's banner image URL, if set. *)
124124+val banner_image : t -> string option
125125+126126+(** [date_published t] returns the item's publication date, if set. *)
127127+val date_published : t -> Ptime.t option
128128+129129+(** [date_modified t] returns the item's last modification date, if set. *)
130130+val date_modified : t -> Ptime.t option
131131+132132+(** [authors t] returns the item's authors, if set. *)
133133+val authors : t -> Author.t list option
134134+135135+(** [tags t] returns the item's tags, if set. *)
136136+val tags : t -> string list option
137137+138138+(** [language t] returns the item's language code, if set. *)
139139+val language : t -> string option
140140+141141+(** [attachments t] returns the item's attachments, if set. *)
142142+val attachments : t -> Attachment.t list option
143143+144144+145145+(** {1 Content Helpers} *)
146146+147147+(** [content_html t] extracts HTML content from the item.
148148+149149+ Returns [Some html] if the item has HTML content (either [Html] or [Both]),
150150+ [None] otherwise. *)
151151+val content_html : t -> string option
152152+153153+(** [content_text t] extracts plain text content from the item.
154154+155155+ Returns [Some text] if the item has plain text content (either [Text] or [Both]),
156156+ [None] otherwise. *)
157157+val content_text : t -> string option
158158+159159+160160+(** {1 Comparison} *)
161161+162162+(** [equal a b] tests equality between two items.
163163+164164+ Items are considered equal if they have the same ID. *)
165165+val equal : t -> t -> bool
166166+167167+(** [compare a b] compares two items by their publication dates.
168168+169169+ Items without publication dates are considered older than items with dates.
170170+ Useful for sorting items chronologically. *)
171171+val compare : t -> t -> int
172172+173173+174174+(** {1 Pretty Printing} *)
175175+176176+(** [pp ppf t] pretty prints an item to the formatter.
177177+178178+ The output is human-readable and suitable for debugging.
179179+180180+ {b Example output:}
181181+ {v [2024-11-03] My First Post (https://example.com/posts/42) v} *)
182182+val pp : Format.formatter -> t -> unit
183183+184184+(** [pp_content ppf content] pretty prints content to the formatter.
185185+186186+ {b Example output:}
187187+ {v HTML (123 chars) v}
188188+ {v Text (56 chars) v}
189189+ {v Both (HTML: 123 chars, Text: 56 chars) v} *)
190190+val pp_content : Format.formatter -> content -> unit
+571
lib/jsonfeed.ml
···11+(** JSON Feed format parser and serializer. *)
22+33+exception Invalid_feed of string
44+55+module Author = Author
66+module Attachment = Attachment
77+module Hub = Hub
88+module Item = Item
99+1010+type t = {
1111+ version : string;
1212+ title : string;
1313+ home_page_url : string option;
1414+ feed_url : string option;
1515+ description : string option;
1616+ user_comment : string option;
1717+ next_url : string option;
1818+ icon : string option;
1919+ favicon : string option;
2020+ authors : Author.t list option;
2121+ language : string option;
2222+ expired : bool option;
2323+ hubs : Hub.t list option;
2424+ items : Item.t list;
2525+}
2626+2727+let create ~title ?home_page_url ?feed_url ?description ?user_comment
2828+ ?next_url ?icon ?favicon ?authors ?language ?expired ?hubs ~items () =
2929+ {
3030+ version = "https://jsonfeed.org/version/1.1";
3131+ title;
3232+ home_page_url;
3333+ feed_url;
3434+ description;
3535+ user_comment;
3636+ next_url;
3737+ icon;
3838+ favicon;
3939+ authors;
4040+ language;
4141+ expired;
4242+ hubs;
4343+ items;
4444+ }
4545+4646+let version t = t.version
4747+let title t = t.title
4848+let home_page_url t = t.home_page_url
4949+let feed_url t = t.feed_url
5050+let description t = t.description
5151+let user_comment t = t.user_comment
5252+let next_url t = t.next_url
5353+let icon t = t.icon
5454+let favicon t = t.favicon
5555+let authors t = t.authors
5656+let language t = t.language
5757+let expired t = t.expired
5858+let hubs t = t.hubs
5959+let items t = t.items
6060+6161+(* RFC3339 date utilities *)
6262+6363+let parse_rfc3339 s =
6464+ match Ptime.of_rfc3339 s with
6565+ | Ok (t, _, _) -> Some t
6666+ | Error _ -> None
6767+6868+let format_rfc3339 t =
6969+ Ptime.to_rfc3339 t
7070+7171+(* JSON parsing and serialization *)
7272+7373+type error = [ `Msg of string ]
7474+7575+let error_msgf fmt = Format.kasprintf (fun s -> Error (`Msg s)) fmt
7676+7777+(* JSON parsing helpers *)
7878+7979+type json_value =
8080+ | Null
8181+ | Bool of bool
8282+ | Float of float
8383+ | String of string
8484+ | Array of json_value list
8585+ | Object of (string * json_value) list
8686+8787+let rec decode_value dec =
8888+ match Jsonm.decode dec with
8989+ | `Lexeme `Null -> Null
9090+ | `Lexeme (`Bool b) -> Bool b
9191+ | `Lexeme (`Float f) -> Float f
9292+ | `Lexeme (`String s) -> String s
9393+ | `Lexeme `Os -> decode_object dec []
9494+ | `Lexeme `As -> decode_array dec []
9595+ | `Lexeme _ -> Null
9696+ | `Error err -> raise (Invalid_feed (Format.asprintf "%a" Jsonm.pp_error err))
9797+ | `End | `Await -> Null
9898+9999+and decode_object dec acc =
100100+ match Jsonm.decode dec with
101101+ | `Lexeme `Oe -> Object (List.rev acc)
102102+ | `Lexeme (`Name n) ->
103103+ let v = decode_value dec in
104104+ decode_object dec ((n, v) :: acc)
105105+ | `Error err -> raise (Invalid_feed (Format.asprintf "%a" Jsonm.pp_error err))
106106+ | _ -> Object (List.rev acc)
107107+108108+and decode_array dec acc =
109109+ match Jsonm.decode dec with
110110+ | `Lexeme `Ae -> Array (List.rev acc)
111111+ | `Lexeme `Os ->
112112+ let v = decode_object dec [] in
113113+ decode_array dec (v :: acc)
114114+ | `Lexeme `As ->
115115+ let v = decode_array dec [] in
116116+ decode_array dec (v :: acc)
117117+ | `Lexeme `Null -> decode_array dec (Null :: acc)
118118+ | `Lexeme (`Bool b) -> decode_array dec (Bool b :: acc)
119119+ | `Lexeme (`Float f) -> decode_array dec (Float f :: acc)
120120+ | `Lexeme (`String s) -> decode_array dec (String s :: acc)
121121+ | `Error err -> raise (Invalid_feed (Format.asprintf "%a" Jsonm.pp_error err))
122122+ | _ -> Array (List.rev acc)
123123+124124+(* Helpers to extract values from JSON *)
125125+126126+let get_string = function String s -> Some s | _ -> None
127127+let get_bool = function Bool b -> Some b | _ -> None
128128+let _get_float = function Float f -> Some f | _ -> None
129129+let get_int = function Float f -> Some (int_of_float f) | _ -> None
130130+let get_int64 = function Float f -> Some (Int64.of_float f) | _ -> None
131131+let get_array = function Array arr -> Some arr | _ -> None
132132+let _get_object = function Object obj -> Some obj | _ -> None
133133+134134+let find_field name obj = List.assoc_opt name obj
135135+136136+let require_field name obj =
137137+ match find_field name obj with
138138+ | Some v -> v
139139+ | None -> raise (Invalid_feed (Printf.sprintf "Missing required field: %s" name))
140140+141141+let require_string name obj =
142142+ match require_field name obj |> get_string with
143143+ | Some s -> s
144144+ | None -> raise (Invalid_feed (Printf.sprintf "Field %s must be a string" name))
145145+146146+let optional_string name obj =
147147+ match find_field name obj with Some v -> get_string v | None -> None
148148+149149+let optional_bool name obj =
150150+ match find_field name obj with Some v -> get_bool v | None -> None
151151+152152+let optional_int name obj =
153153+ match find_field name obj with Some v -> get_int v | None -> None
154154+155155+let optional_int64 name obj =
156156+ match find_field name obj with Some v -> get_int64 v | None -> None
157157+158158+let optional_array name obj =
159159+ match find_field name obj with Some v -> get_array v | None -> None
160160+161161+(* Parse Author *)
162162+163163+let parse_author_obj obj =
164164+ let name = optional_string "name" obj in
165165+ let url = optional_string "url" obj in
166166+ let avatar = optional_string "avatar" obj in
167167+ if name = None && url = None && avatar = None then
168168+ raise (Invalid_feed "Author must have at least one field");
169169+ Author.create ?name ?url ?avatar ()
170170+171171+let parse_author = function
172172+ | Object obj -> parse_author_obj obj
173173+ | _ -> raise (Invalid_feed "Author must be an object")
174174+175175+(* Parse Attachment *)
176176+177177+let parse_attachment_obj obj =
178178+ let url = require_string "url" obj in
179179+ let mime_type = require_string "mime_type" obj in
180180+ let title = optional_string "title" obj in
181181+ let size_in_bytes = optional_int64 "size_in_bytes" obj in
182182+ let duration_in_seconds = optional_int "duration_in_seconds" obj in
183183+ Attachment.create ~url ~mime_type ?title ?size_in_bytes ?duration_in_seconds ()
184184+185185+let parse_attachment = function
186186+ | Object obj -> parse_attachment_obj obj
187187+ | _ -> raise (Invalid_feed "Attachment must be an object")
188188+189189+(* Parse Hub *)
190190+191191+let parse_hub_obj obj =
192192+ let type_ = require_string "type" obj in
193193+ let url = require_string "url" obj in
194194+ Hub.create ~type_ ~url ()
195195+196196+let parse_hub = function
197197+ | Object obj -> parse_hub_obj obj
198198+ | _ -> raise (Invalid_feed "Hub must be an object")
199199+200200+(* Parse Item *)
201201+202202+let parse_item_obj obj =
203203+ let id = require_string "id" obj in
204204+205205+ (* Parse content - at least one required *)
206206+ let content_html = optional_string "content_html" obj in
207207+ let content_text = optional_string "content_text" obj in
208208+ let content = match content_html, content_text with
209209+ | Some html, Some text -> `Both (html, text)
210210+ | Some html, None -> `Html html
211211+ | None, Some text -> `Text text
212212+ | None, None ->
213213+ raise (Invalid_feed "Item must have content_html or content_text")
214214+ in
215215+216216+ let url = optional_string "url" obj in
217217+ let external_url = optional_string "external_url" obj in
218218+ let title = optional_string "title" obj in
219219+ let summary = optional_string "summary" obj in
220220+ let image = optional_string "image" obj in
221221+ let banner_image = optional_string "banner_image" obj in
222222+223223+ let date_published =
224224+ match optional_string "date_published" obj with
225225+ | Some s -> parse_rfc3339 s
226226+ | None -> None
227227+ in
228228+229229+ let date_modified =
230230+ match optional_string "date_modified" obj with
231231+ | Some s -> parse_rfc3339 s
232232+ | None -> None
233233+ in
234234+235235+ let authors =
236236+ match optional_array "authors" obj with
237237+ | Some arr ->
238238+ let parsed = List.map parse_author arr in
239239+ if parsed = [] then None else Some parsed
240240+ | None -> None
241241+ in
242242+243243+ let tags =
244244+ match optional_array "tags" obj with
245245+ | Some arr ->
246246+ let parsed = List.filter_map get_string arr in
247247+ if parsed = [] then None else Some parsed
248248+ | None -> None
249249+ in
250250+251251+ let language = optional_string "language" obj in
252252+253253+ let attachments =
254254+ match optional_array "attachments" obj with
255255+ | Some arr ->
256256+ let parsed = List.map parse_attachment arr in
257257+ if parsed = [] then None else Some parsed
258258+ | None -> None
259259+ in
260260+261261+ Item.create ~id ~content ?url ?external_url ?title ?summary ?image
262262+ ?banner_image ?date_published ?date_modified ?authors ?tags ?language
263263+ ?attachments ()
264264+265265+let parse_item = function
266266+ | Object obj -> parse_item_obj obj
267267+ | _ -> raise (Invalid_feed "Item must be an object")
268268+269269+(* Parse Feed *)
270270+271271+let parse_feed_obj obj =
272272+ let version = require_string "version" obj in
273273+ let title = require_string "title" obj in
274274+ let home_page_url = optional_string "home_page_url" obj in
275275+ let feed_url = optional_string "feed_url" obj in
276276+ let description = optional_string "description" obj in
277277+ let user_comment = optional_string "user_comment" obj in
278278+ let next_url = optional_string "next_url" obj in
279279+ let icon = optional_string "icon" obj in
280280+ let favicon = optional_string "favicon" obj in
281281+ let language = optional_string "language" obj in
282282+ let expired = optional_bool "expired" obj in
283283+284284+ let authors =
285285+ match optional_array "authors" obj with
286286+ | Some arr ->
287287+ let parsed = List.map parse_author arr in
288288+ if parsed = [] then None else Some parsed
289289+ | None -> None
290290+ in
291291+292292+ let hubs =
293293+ match optional_array "hubs" obj with
294294+ | Some arr ->
295295+ let parsed = List.map parse_hub arr in
296296+ if parsed = [] then None else Some parsed
297297+ | None -> None
298298+ in
299299+300300+ let items =
301301+ match optional_array "items" obj with
302302+ | Some arr -> List.map parse_item arr
303303+ | None -> []
304304+ in
305305+306306+ {
307307+ version;
308308+ title;
309309+ home_page_url;
310310+ feed_url;
311311+ description;
312312+ user_comment;
313313+ next_url;
314314+ icon;
315315+ favicon;
316316+ authors;
317317+ language;
318318+ expired;
319319+ hubs;
320320+ items;
321321+ }
322322+323323+let of_jsonm dec =
324324+ try
325325+ let json = decode_value dec in
326326+ match json with
327327+ | Object obj -> Ok (parse_feed_obj obj)
328328+ | _ -> error_msgf "Feed must be a JSON object"
329329+ with
330330+ | Invalid_feed msg -> error_msgf "%s" msg
331331+332332+(* JSON serialization *)
333333+334334+let to_jsonm enc feed =
335335+ (* Simplified serialization using Jsonm *)
336336+ let enc_field name value_fn =
337337+ ignore (Jsonm.encode enc (`Lexeme (`Name name)));
338338+ value_fn ()
339339+ in
340340+341341+ let enc_string s =
342342+ ignore (Jsonm.encode enc (`Lexeme (`String s)))
343343+ in
344344+345345+ let enc_bool b =
346346+ ignore (Jsonm.encode enc (`Lexeme (`Bool b)))
347347+ in
348348+349349+ let enc_opt enc_fn = function
350350+ | None -> ()
351351+ | Some v -> enc_fn v
352352+ in
353353+354354+ let enc_list enc_fn lst =
355355+ ignore (Jsonm.encode enc (`Lexeme `As));
356356+ List.iter enc_fn lst;
357357+ ignore (Jsonm.encode enc (`Lexeme `Ae))
358358+ in
359359+360360+ let enc_author author =
361361+ ignore (Jsonm.encode enc (`Lexeme `Os));
362362+ (match Author.name author with
363363+ | Some name -> enc_field "name" (fun () -> enc_string name)
364364+ | None -> ());
365365+ (match Author.url author with
366366+ | Some url -> enc_field "url" (fun () -> enc_string url)
367367+ | None -> ());
368368+ (match Author.avatar author with
369369+ | Some avatar -> enc_field "avatar" (fun () -> enc_string avatar)
370370+ | None -> ());
371371+ ignore (Jsonm.encode enc (`Lexeme `Oe))
372372+ in
373373+374374+ let enc_attachment att =
375375+ ignore (Jsonm.encode enc (`Lexeme `Os));
376376+ enc_field "url" (fun () -> enc_string (Attachment.url att));
377377+ enc_field "mime_type" (fun () -> enc_string (Attachment.mime_type att));
378378+ enc_opt (fun title -> enc_field "title" (fun () -> enc_string title))
379379+ (Attachment.title att);
380380+ enc_opt (fun size ->
381381+ enc_field "size_in_bytes" (fun () ->
382382+ ignore (Jsonm.encode enc (`Lexeme (`Float (Int64.to_float size))))))
383383+ (Attachment.size_in_bytes att);
384384+ enc_opt (fun dur ->
385385+ enc_field "duration_in_seconds" (fun () ->
386386+ ignore (Jsonm.encode enc (`Lexeme (`Float (float_of_int dur))))))
387387+ (Attachment.duration_in_seconds att);
388388+ ignore (Jsonm.encode enc (`Lexeme `Oe))
389389+ in
390390+391391+ let enc_hub hub =
392392+ ignore (Jsonm.encode enc (`Lexeme `Os));
393393+ enc_field "type" (fun () -> enc_string (Hub.type_ hub));
394394+ enc_field "url" (fun () -> enc_string (Hub.url hub));
395395+ ignore (Jsonm.encode enc (`Lexeme `Oe))
396396+ in
397397+398398+ let enc_item item =
399399+ ignore (Jsonm.encode enc (`Lexeme `Os));
400400+ enc_field "id" (fun () -> enc_string (Item.id item));
401401+402402+ (* Encode content *)
403403+ (match Item.content item with
404404+ | `Html html ->
405405+ enc_field "content_html" (fun () -> enc_string html)
406406+ | `Text text ->
407407+ enc_field "content_text" (fun () -> enc_string text)
408408+ | `Both (html, text) ->
409409+ enc_field "content_html" (fun () -> enc_string html);
410410+ enc_field "content_text" (fun () -> enc_string text));
411411+412412+ enc_opt (fun url -> enc_field "url" (fun () -> enc_string url))
413413+ (Item.url item);
414414+ enc_opt (fun url -> enc_field "external_url" (fun () -> enc_string url))
415415+ (Item.external_url item);
416416+ enc_opt (fun title -> enc_field "title" (fun () -> enc_string title))
417417+ (Item.title item);
418418+ enc_opt (fun summary -> enc_field "summary" (fun () -> enc_string summary))
419419+ (Item.summary item);
420420+ enc_opt (fun img -> enc_field "image" (fun () -> enc_string img))
421421+ (Item.image item);
422422+ enc_opt (fun img -> enc_field "banner_image" (fun () -> enc_string img))
423423+ (Item.banner_image item);
424424+ enc_opt (fun date -> enc_field "date_published" (fun () -> enc_string (format_rfc3339 date)))
425425+ (Item.date_published item);
426426+ enc_opt (fun date -> enc_field "date_modified" (fun () -> enc_string (format_rfc3339 date)))
427427+ (Item.date_modified item);
428428+ enc_opt (fun authors ->
429429+ enc_field "authors" (fun () -> enc_list enc_author authors))
430430+ (Item.authors item);
431431+ enc_opt (fun tags ->
432432+ enc_field "tags" (fun () -> enc_list enc_string tags))
433433+ (Item.tags item);
434434+ enc_opt (fun lang -> enc_field "language" (fun () -> enc_string lang))
435435+ (Item.language item);
436436+ enc_opt (fun atts ->
437437+ enc_field "attachments" (fun () -> enc_list enc_attachment atts))
438438+ (Item.attachments item);
439439+440440+ ignore (Jsonm.encode enc (`Lexeme `Oe))
441441+ in
442442+443443+ (* Encode the feed *)
444444+ ignore (Jsonm.encode enc (`Lexeme `Os));
445445+ enc_field "version" (fun () -> enc_string feed.version);
446446+ enc_field "title" (fun () -> enc_string feed.title);
447447+ enc_opt (fun url -> enc_field "home_page_url" (fun () -> enc_string url))
448448+ feed.home_page_url;
449449+ enc_opt (fun url -> enc_field "feed_url" (fun () -> enc_string url))
450450+ feed.feed_url;
451451+ enc_opt (fun desc -> enc_field "description" (fun () -> enc_string desc))
452452+ feed.description;
453453+ enc_opt (fun comment -> enc_field "user_comment" (fun () -> enc_string comment))
454454+ feed.user_comment;
455455+ enc_opt (fun url -> enc_field "next_url" (fun () -> enc_string url))
456456+ feed.next_url;
457457+ enc_opt (fun icon -> enc_field "icon" (fun () -> enc_string icon))
458458+ feed.icon;
459459+ enc_opt (fun favicon -> enc_field "favicon" (fun () -> enc_string favicon))
460460+ feed.favicon;
461461+ enc_opt (fun authors ->
462462+ enc_field "authors" (fun () -> enc_list enc_author authors))
463463+ feed.authors;
464464+ enc_opt (fun lang -> enc_field "language" (fun () -> enc_string lang))
465465+ feed.language;
466466+ enc_opt (fun expired -> enc_field "expired" (fun () -> enc_bool expired))
467467+ feed.expired;
468468+ enc_opt (fun hubs ->
469469+ enc_field "hubs" (fun () -> enc_list enc_hub hubs))
470470+ feed.hubs;
471471+ enc_field "items" (fun () -> enc_list enc_item feed.items);
472472+ ignore (Jsonm.encode enc (`Lexeme `Oe));
473473+ ignore (Jsonm.encode enc `End)
474474+475475+let of_string s =
476476+ let dec = Jsonm.decoder (`String s) in
477477+ of_jsonm dec
478478+479479+let to_string ?(minify=false) feed =
480480+ let buf = Buffer.create 1024 in
481481+ let enc = Jsonm.encoder ~minify (`Buffer buf) in
482482+ to_jsonm enc feed;
483483+ Buffer.contents buf
484484+485485+(* Validation *)
486486+487487+let validate feed =
488488+ let errors = ref [] in
489489+ let add_error msg = errors := msg :: !errors in
490490+491491+ (* Check required fields *)
492492+ if feed.title = "" then
493493+ add_error "title is required and cannot be empty";
494494+495495+ (* Check items have unique IDs *)
496496+ let ids = List.map Item.id feed.items in
497497+ let unique_ids = List.sort_uniq String.compare ids in
498498+ if List.length ids <> List.length unique_ids then
499499+ add_error "items must have unique IDs";
500500+501501+ (* Validate authors *)
502502+ (match feed.authors with
503503+ | Some authors ->
504504+ List.iteri (fun i author ->
505505+ if not (Author.is_valid author) then
506506+ add_error (Printf.sprintf "feed author %d is invalid (needs at least one field)" i)
507507+ ) authors
508508+ | None -> ());
509509+510510+ (* Validate items *)
511511+ List.iteri (fun i item ->
512512+ if Item.id item = "" then
513513+ add_error (Printf.sprintf "item %d has empty ID" i);
514514+515515+ (* Validate item authors *)
516516+ (match Item.authors item with
517517+ | Some authors ->
518518+ List.iteri (fun j author ->
519519+ if not (Author.is_valid author) then
520520+ add_error (Printf.sprintf "item %d author %d is invalid" i j)
521521+ ) authors
522522+ | None -> ())
523523+ ) feed.items;
524524+525525+ if !errors = [] then Ok ()
526526+ else Error (List.rev !errors)
527527+528528+(* Comparison *)
529529+530530+let equal a b =
531531+ a.version = b.version &&
532532+ a.title = b.title &&
533533+ a.home_page_url = b.home_page_url &&
534534+ a.feed_url = b.feed_url &&
535535+ a.description = b.description &&
536536+ a.user_comment = b.user_comment &&
537537+ a.next_url = b.next_url &&
538538+ a.icon = b.icon &&
539539+ a.favicon = b.favicon &&
540540+ a.language = b.language &&
541541+ a.expired = b.expired &&
542542+ (* Note: We're doing structural equality on items *)
543543+ List.length a.items = List.length b.items
544544+545545+(* Pretty printing *)
546546+547547+let pp_summary ppf feed =
548548+ Format.fprintf ppf "%s (%d items)" feed.title (List.length feed.items)
549549+550550+let pp ppf feed =
551551+ Format.fprintf ppf "Feed: %s" feed.title;
552552+ (match feed.home_page_url with
553553+ | Some url -> Format.fprintf ppf " (%s)" url
554554+ | None -> ());
555555+ Format.fprintf ppf "@\n";
556556+557557+ Format.fprintf ppf " Items: %d@\n" (List.length feed.items);
558558+559559+ (match feed.authors with
560560+ | Some authors when authors <> [] ->
561561+ Format.fprintf ppf " Authors: ";
562562+ List.iteri (fun i author ->
563563+ if i > 0 then Format.fprintf ppf ", ";
564564+ Format.fprintf ppf "%a" Author.pp author
565565+ ) authors;
566566+ Format.fprintf ppf "@\n"
567567+ | _ -> ());
568568+569569+ (match feed.language with
570570+ | Some lang -> Format.fprintf ppf " Language: %s@\n" lang
571571+ | None -> ())
+376
lib/jsonfeed.mli
···11+(** JSON Feed format parser and serializer.
22+33+ 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 Key Features:}
88+ - Type-safe construction with compile-time validation
99+ - Support for all JSON Feed 1.1 fields
1010+ - RFC 3339 date parsing with Ptime integration
1111+ - Streaming parsing and serialization with Jsonm
1212+ - Comprehensive documentation and examples
1313+1414+ {b Quick Start:}
1515+ {[
1616+ (* Create a simple feed *)
1717+ let feed = Jsonfeed.create
1818+ ~title:"My Blog"
1919+ ~home_page_url:"https://example.com"
2020+ ~feed_url:"https://example.com/feed.json"
2121+ ~items:[
2222+ Item.create
2323+ ~id:"https://example.com/post/1"
2424+ ~content:(Item.Html "<p>Hello, world!</p>")
2525+ ~title:"First Post"
2626+ ()
2727+ ]
2828+ ()
2929+3030+ (* Serialize to string *)
3131+ let json = Jsonfeed.to_string feed
3232+3333+ (* Parse from string *)
3434+ match Jsonfeed.of_string json with
3535+ | Ok feed -> Printf.printf "Feed: %s\n" (Jsonfeed.title feed)
3636+ | Error (`Msg err) -> Printf.eprintf "Error: %s\n" err
3737+ ]}
3838+3939+ @see <https://www.jsonfeed.org/version/1.1/> JSON Feed Specification *)
4040+4141+4242+(** The type representing a complete JSON Feed. *)
4343+type t
4444+4545+(** Exception raised when attempting to parse an invalid feed. *)
4646+exception Invalid_feed of string
4747+4848+(** {1 Construction} *)
4949+5050+(** [create ~title ?home_page_url ?feed_url ?description ?user_comment ?next_url
5151+ ?icon ?favicon ?authors ?language ?expired ?hubs ~items ()]
5252+ creates a JSON Feed.
5353+5454+ @param title The name of the feed (required)
5555+ @param home_page_url The URL of the resource the feed describes
5656+ @param feed_url The URL of the feed itself (serves as unique identifier)
5757+ @param description Additional information about the feed
5858+ @param user_comment A description of the feed's purpose for humans reading the raw JSON
5959+ @param next_url URL of the next page of items (for pagination)
6060+ @param icon The feed's icon URL (should be square, 512x512 or larger)
6161+ @param favicon The feed's favicon URL (should be square, 64x64 or larger)
6262+ @param authors The feed's default authors (inherited by items without authors)
6363+ @param language The primary language of the feed (RFC 5646 format, e.g. ["en-US"])
6464+ @param expired Whether the feed will update again ([true] means no more updates)
6565+ @param hubs Endpoints for real-time notifications
6666+ @param items The list of feed items (required)
6767+6868+ {b Examples:}
6969+ {[
7070+ (* Minimal feed *)
7171+ let feed = Jsonfeed.create
7272+ ~title:"My Blog"
7373+ ~items:[] ()
7474+7575+ (* Full-featured blog feed *)
7676+ let feed = Jsonfeed.create
7777+ ~title:"Example Blog"
7878+ ~home_page_url:"https://example.com"
7979+ ~feed_url:"https://example.com/feed.json"
8080+ ~description:"A blog about OCaml and functional programming"
8181+ ~icon:"https://example.com/icon.png"
8282+ ~authors:[
8383+ Author.create
8484+ ~name:"Jane Doe"
8585+ ~url:"https://example.com/about"
8686+ ()
8787+ ]
8888+ ~language:"en-US"
8989+ ~items:[
9090+ Item.create
9191+ ~id:"https://example.com/posts/1"
9292+ ~content:(Item.Html "<p>First post</p>")
9393+ ~title:"Hello World"
9494+ ();
9595+ Item.create
9696+ ~id:"https://example.com/posts/2"
9797+ ~content:(Item.Html "<p>Second post</p>")
9898+ ~title:"Another Post"
9999+ ()
100100+ ]
101101+ ()
102102+103103+ (* Podcast feed with hubs *)
104104+ let hub = Hub.create
105105+ ~type_:"WebSub"
106106+ ~url:"https://pubsubhubbub.appspot.com/"
107107+ () in
108108+ let feed = Jsonfeed.create
109109+ ~title:"My Podcast"
110110+ ~home_page_url:"https://podcast.example.com"
111111+ ~feed_url:"https://podcast.example.com/feed.json"
112112+ ~hubs:[hub]
113113+ ~items:[
114114+ Item.create
115115+ ~id:"https://podcast.example.com/episodes/1"
116116+ ~content:(Item.Html "<p>Episode description</p>")
117117+ ~title:"Episode 1"
118118+ ~attachments:[
119119+ Attachment.create
120120+ ~url:"https://podcast.example.com/ep1.mp3"
121121+ ~mime_type:"audio/mpeg"
122122+ ~duration_in_seconds:1800
123123+ ()
124124+ ]
125125+ ()
126126+ ]
127127+ ()
128128+ ]} *)
129129+val create :
130130+ title:string ->
131131+ ?home_page_url:string ->
132132+ ?feed_url:string ->
133133+ ?description:string ->
134134+ ?user_comment:string ->
135135+ ?next_url:string ->
136136+ ?icon:string ->
137137+ ?favicon:string ->
138138+ ?authors:Author.t list ->
139139+ ?language:string ->
140140+ ?expired:bool ->
141141+ ?hubs:Hub.t list ->
142142+ items:Item.t list ->
143143+ unit ->
144144+ t
145145+146146+147147+(** {1 Accessors} *)
148148+149149+(** [version t] returns the JSON Feed version URL.
150150+151151+ This is always ["https://jsonfeed.org/version/1.1"] for feeds created
152152+ by this library, but may differ when parsing external feeds. *)
153153+val version : t -> string
154154+155155+(** [title t] returns the feed's title. *)
156156+val title : t -> string
157157+158158+(** [home_page_url t] returns the feed's home page URL, if set. *)
159159+val home_page_url : t -> string option
160160+161161+(** [feed_url t] returns the feed's URL, if set. *)
162162+val feed_url : t -> string option
163163+164164+(** [description t] returns the feed's description, if set. *)
165165+val description : t -> string option
166166+167167+(** [user_comment t] returns the feed's user comment, if set. *)
168168+val user_comment : t -> string option
169169+170170+(** [next_url t] returns the URL for the next page of items, if set. *)
171171+val next_url : t -> string option
172172+173173+(** [icon t] returns the feed's icon URL, if set. *)
174174+val icon : t -> string option
175175+176176+(** [favicon t] returns the feed's favicon URL, if set. *)
177177+val favicon : t -> string option
178178+179179+(** [authors t] returns the feed's default authors, if set. *)
180180+val authors : t -> Author.t list option
181181+182182+(** [language t] returns the feed's primary language, if set. *)
183183+val language : t -> string option
184184+185185+(** [expired t] returns whether the feed will update again. *)
186186+val expired : t -> bool option
187187+188188+(** [hubs t] returns the feed's hub endpoints, if set. *)
189189+val hubs : t -> Hub.t list option
190190+191191+(** [items t] returns the feed's items. *)
192192+val items : t -> Item.t list
193193+194194+195195+(** {1 Parsing and Serialization} *)
196196+197197+(** Error type for parsing operations. *)
198198+type error = [ `Msg of string ]
199199+200200+(** [of_jsonm decoder] parses a JSON Feed from a Jsonm decoder.
201201+202202+ This is the lowest-level parsing function, suitable for integration
203203+ with streaming JSON processing pipelines.
204204+205205+ @param decoder A Jsonm decoder positioned at the start of a JSON Feed document
206206+ @return [Ok feed] on success, [Error (`Msg err)] on parse error
207207+208208+ {b Example:}
209209+ {[
210210+ let decoder = Jsonm.decoder (`String json_string) in
211211+ match Jsonfeed.of_jsonm decoder with
212212+ | Ok feed -> (* process feed *)
213213+ | Error (`Msg err) -> (* handle error *)
214214+ ]} *)
215215+val of_jsonm : Jsonm.decoder -> (t, [> error]) result
216216+217217+(** [to_jsonm encoder feed] serializes a JSON Feed to a Jsonm encoder.
218218+219219+ This is the lowest-level serialization function, suitable for integration
220220+ with streaming JSON generation pipelines.
221221+222222+ @param encoder A Jsonm encoder
223223+ @param feed The feed to serialize
224224+225225+ {b Example:}
226226+ {[
227227+ let buffer = Buffer.create 1024 in
228228+ let encoder = Jsonm.encoder (`Buffer buffer) in
229229+ Jsonfeed.to_jsonm encoder feed;
230230+ let json = Buffer.contents buffer
231231+ ]} *)
232232+val to_jsonm : Jsonm.encoder -> t -> unit
233233+234234+(** [of_string s] parses a JSON Feed from a string.
235235+236236+ @param s A JSON string containing a JSON Feed document
237237+ @return [Ok feed] on success, [Error (`Msg err)] on parse error
238238+239239+ {b Example:}
240240+ {[
241241+ let json = {|{
242242+ "version": "https://jsonfeed.org/version/1.1",
243243+ "title": "My Feed",
244244+ "items": []
245245+ }|} in
246246+ match Jsonfeed.of_string json with
247247+ | Ok feed -> Printf.printf "Parsed: %s\n" (Jsonfeed.title feed)
248248+ | Error (`Msg err) -> Printf.eprintf "Error: %s\n" err
249249+ ]} *)
250250+val of_string : string -> (t, [> error]) result
251251+252252+(** [to_string ?minify feed] serializes a JSON Feed to a string.
253253+254254+ @param minify If [true], produces compact JSON without whitespace.
255255+ If [false] (default), produces indented, human-readable JSON.
256256+ @param feed The feed to serialize
257257+ @return A JSON string
258258+259259+ {b Example:}
260260+ {[
261261+ let json = Jsonfeed.to_string feed
262262+ let compact = Jsonfeed.to_string ~minify:true feed
263263+ ]} *)
264264+val to_string : ?minify:bool -> t -> string
265265+266266+267267+(** {1 Date Utilities} *)
268268+269269+(** [parse_rfc3339 s] parses an RFC 3339 date/time string.
270270+271271+ This function parses timestamps in the format required by JSON Feed,
272272+ such as ["2024-11-03T10:30:00Z"] or ["2024-11-03T10:30:00-08:00"].
273273+274274+ @param s An RFC 3339 formatted date/time string
275275+ @return [Some time] on success, [None] if the string is invalid
276276+277277+ {b Examples:}
278278+ {[
279279+ parse_rfc3339 "2024-11-03T10:30:00Z"
280280+ (* returns Some time *)
281281+282282+ parse_rfc3339 "2024-11-03T10:30:00-08:00"
283283+ (* returns Some time *)
284284+285285+ parse_rfc3339 "invalid"
286286+ (* returns None *)
287287+ ]} *)
288288+val parse_rfc3339 : string -> Ptime.t option
289289+290290+(** [format_rfc3339 time] formats a timestamp as an RFC 3339 string.
291291+292292+ The output uses UTC timezone (Z suffix) and includes fractional seconds
293293+ if the timestamp has sub-second precision.
294294+295295+ @param time A Ptime timestamp
296296+ @return An RFC 3339 formatted string
297297+298298+ {b Example:}
299299+ {[
300300+ let now = Ptime_clock.now () in
301301+ let s = format_rfc3339 now
302302+ (* returns "2024-11-03T10:30:45.123Z" or similar *)
303303+ ]} *)
304304+val format_rfc3339 : Ptime.t -> string
305305+306306+307307+(** {1 Validation} *)
308308+309309+(** [validate feed] validates a JSON Feed.
310310+311311+ Checks that:
312312+ - All required fields are present
313313+ - All items have unique IDs
314314+ - All items have valid content
315315+ - All URLs are well-formed (if possible)
316316+ - Authors have at least one field set
317317+318318+ @param feed The feed to validate
319319+ @return [Ok ()] if valid, [Error errors] with a list of validation issues
320320+321321+ {b Example:}
322322+ {[
323323+ match Jsonfeed.validate feed with
324324+ | Ok () -> (* feed is valid *)
325325+ | Error errors ->
326326+ List.iter (Printf.eprintf "Validation error: %s\n") errors
327327+ ]} *)
328328+val validate : t -> (unit, string list) result
329329+330330+331331+(** {1 Comparison} *)
332332+333333+(** [equal a b] tests equality between two feeds.
334334+335335+ Feeds are compared structurally, including all fields and items. *)
336336+val equal : t -> t -> bool
337337+338338+339339+(** {1 Pretty Printing} *)
340340+341341+(** [pp ppf feed] pretty prints a feed to the formatter.
342342+343343+ The output is human-readable and suitable for debugging. It shows
344344+ the feed's metadata and a summary of items.
345345+346346+ {b Example output:}
347347+ {v
348348+ Feed: My Blog (https://example.com)
349349+ Items: 2
350350+ Authors: Jane Doe
351351+ Language: en-US
352352+ v} *)
353353+val pp : Format.formatter -> t -> unit
354354+355355+(** [pp_summary ppf feed] prints a brief summary of the feed.
356356+357357+ Shows only the title and item count.
358358+359359+ {b Example output:}
360360+ {v My Blog (2 items) v} *)
361361+val pp_summary : Format.formatter -> t -> unit
362362+363363+364364+(** {1 Feed Content} *)
365365+366366+(** Author information for feeds and items. *)
367367+module Author = Author
368368+369369+(** Attachments for feed items (audio, video, downloads). *)
370370+module Attachment = Attachment
371371+372372+(** Hub endpoints for real-time notifications. *)
373373+module Hub = Hub
374374+375375+(** Feed items (posts, episodes, entries). *)
376376+module Item = Item