(* * Copyright (c) 2014, OCaml.org project * Copyright (c) 2015 KC Sivaramakrishnan * * Permission to use, copy, modify, and distribute this software for any * purpose with or without fee is hereby granted, provided that the above * copyright notice and this permission notice appear in all copies. * * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *) (** Feed format conversion and export. *) module Atom = struct let entry_of_post post = let content = Syndic.Atom.Html (None, Post.content post) in let contributors = [ Syndic.Atom.author ~uri:(Uri.of_string (Source.url (Feed.source (Post.feed post)))) (Source.name (Feed.source (Post.feed post))) ] in let links = match Post.link post with | Some l -> [ Syndic.Atom.link ~rel:Syndic.Atom.Alternate l ] | None -> [] in let id = match Post.link post with | Some l -> l | None -> Uri.of_string (Digest.to_hex (Digest.string (Post.title post))) in let authors = (Syndic.Atom.author ~email:(Post.email post) (Post.author post), []) in let title : Syndic.Atom.text_construct = Syndic.Atom.Text (Post.title post) in let updated = match Post.date post with (* Atom entry requires a date but RSS2 does not. So if a date * is not available, just capture the current date. *) | None -> Ptime.of_float_s (Unix.gettimeofday ()) |> Option.get | Some d -> d in let categories = List.map (fun tag -> Syndic.Atom.category tag) (Post.tags post) in Syndic.Atom.entry ~content ~contributors ~links ~id ~authors ~title ~updated ~categories () let entries_of_posts posts = List.map entry_of_post posts let feed_of_entries ~title ?id ?(authors = []) entries = let feed_id = match id with | Some i -> Uri.of_string i | None -> Uri.of_string "urn:river:merged" in let feed_authors = List.map (fun (name, email) -> match email with | Some e -> Syndic.Atom.author ~email:e name | None -> Syndic.Atom.author name ) authors in { Syndic.Atom.id = feed_id; title = Syndic.Atom.Text title; updated = Ptime.of_float_s (Unix.time ()) |> Option.get; entries; authors = feed_authors; categories = []; contributors = []; generator = Some { Syndic.Atom.version = Some "1.0"; uri = None; content = "River Feed Aggregator"; }; icon = None; links = []; logo = None; rights = None; subtitle = None; } let to_string feed = let output = Buffer.create 4096 in Syndic.Atom.output feed (`Buffer output); Buffer.contents output end module Rss2 = struct let of_feed feed = (* Feed content is now always JSONFeed - cannot extract RSS2 directly *) (* This function is kept for backwards compatibility but always returns None *) let _ = feed in None end module Jsonfeed = struct let item_of_post post = (* Convert HTML content back to string *) let html = Post.content post in let content = `Html html in (* Create author *) let authors = if Post.author post <> "" then let author = Jsonfeed.Author.create ~name:(Post.author post) () in Some [author] else None in (* Create item *) Jsonfeed.Item.create ~id:(Post.id post) ~content ?url:(Option.map Uri.to_string (Post.link post)) ~title:(Post.title post) ?summary:(Post.summary post) ?date_published:(Post.date post) ?authors ~tags:(Post.tags post) () let items_of_posts posts = List.map item_of_post posts let feed_of_items ~title ?home_page_url ?feed_url ?description ?icon ?favicon items = Jsonfeed.create ~title ?home_page_url ?feed_url ?description ?icon ?favicon ~items () let feed_of_posts ~title ?home_page_url ?feed_url ?description ?icon ?favicon posts = let items = items_of_posts posts in feed_of_items ~title ?home_page_url ?feed_url ?description ?icon ?favicon items let to_string ?(minify = false) jsonfeed = match Jsonfeed.to_string ~minify jsonfeed with | Ok s -> Ok s | Error err -> Error (Jsont.Error.to_string err) let of_feed feed = (* Feed content is now always River_jsonfeed.t - extract the inner Jsonfeed.t *) let jsonfeed_content = Feed.content feed in Some jsonfeed_content.River_jsonfeed.feed end module Html = struct (** HTML static site generation. *) let css = {| * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; line-height: 1.5; color: #333; background: #fff; max-width: 900px; margin: 0 auto; padding: 15px; } header { border-bottom: 1px solid #e1e4e8; padding-bottom: 10px; margin-bottom: 20px; } header h1 { font-size: 22px; font-weight: 600; margin-bottom: 6px; } header h1 a { color: #333; text-decoration: none; } nav { font-size: 13px; } nav a { color: #586069; text-decoration: none; margin-right: 12px; } nav a:hover { color: #0366d6; } .post { margin-bottom: 30px; padding-bottom: 25px; border-bottom: 1px solid #e1e4e8; overflow: hidden; } .post:last-child { border-bottom: none; } .author-thumbnail { float: right; width: 64px; height: 64px; border-radius: 50%; object-fit: cover; margin-left: 15px; margin-bottom: 10px; border: 2px solid #e1e4e8; transition: transform 0.2s, box-shadow 0.2s; } .author-thumbnail:hover { transform: scale(1.05); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); } .post-title { font-size: 18px; font-weight: 600; margin-bottom: 4px; line-height: 1.3; } .post-title a { color: #0366d6; text-decoration: none; } .post-title a:hover { text-decoration: underline; } .post-meta-line { font-size: 12px; color: #586069; margin-bottom: 10px; } .post-meta-line a { color: #586069; text-decoration: none; font-weight: 600; } .post-meta-line a:hover { color: #0366d6; } @media (max-width: 768px) { .author-thumbnail { float: none; display: block; margin: 0 auto 10px auto; } } .post-excerpt { font-size: 14px; color: #24292e; line-height: 1.5; } .post-excerpt p { margin-bottom: 8px; } .post-excerpt ul, .post-excerpt ol { margin-left: 20px; margin-bottom: 8px; } .post-excerpt li { margin-bottom: 3px; } .post-excerpt code { background: #f6f8fa; padding: 2px 4px; border-radius: 3px; font-size: 13px; } .post-excerpt img { float: right; width: 35%; max-width: 300px; margin: 0 0 10px 15px; border-radius: 4px; cursor: pointer; transition: opacity 0.2s; } .post-excerpt img:hover { opacity: 0.9; } @media (max-width: 600px) { .post-excerpt img { float: none; width: 100%; max-width: 100%; margin: 10px 0; } } .lightbox { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.9); z-index: 1000; cursor: pointer; align-items: center; justify-content: center; } .lightbox.active { display: flex; } .lightbox img { max-width: 95%; max-height: 95%; object-fit: contain; } .post-full-content { display: none; font-size: 14px; color: #24292e; line-height: 1.5; margin-top: 10px; } .post-full-content.active { display: block; } .post-full-content p { margin-bottom: 10px; } .post-full-content ul, .post-full-content ol { margin-left: 20px; margin-bottom: 10px; } .post-full-content li { margin-bottom: 4px; } .post-full-content h1, .post-full-content h2, .post-full-content h3 { margin-top: 15px; margin-bottom: 8px; } .post-full-content h1 { font-size: 18px; font-weight: 600; } .post-full-content h2 { font-size: 16px; font-weight: 600; } .post-full-content h3 { font-size: 15px; font-weight: 600; } .post-full-content code { background: #f6f8fa; padding: 2px 4px; border-radius: 3px; font-size: 13px; } .post-full-content pre { background: #f6f8fa; padding: 10px; border-radius: 4px; overflow-x: auto; margin-bottom: 10px; } .post-full-content pre code { background: none; padding: 0; } .post-full-content blockquote { border-left: 3px solid #e1e4e8; padding-left: 12px; margin: 10px 0; color: #586069; } .post-full-content img { max-width: 100%; height: auto; margin: 10px 0; border-radius: 4px; } .read-more { display: inline-block; color: #0366d6; font-size: 11px; cursor: pointer; text-decoration: none; padding: 2px 8px; border: 1px solid #e1e4e8; border-radius: 3px; background: #f6f8fa; transition: background 0.2s; margin-right: 6px; vertical-align: middle; } .read-more:hover { background: #e1e4e8; } .read-more::after { content: ' ▼'; font-size: 9px; } .read-more.active::after { content: ' ▲'; } .post-tags { margin-top: 8px; font-size: 11px; clear: both; display: inline-block; } .post-tags a { display: inline-block; background: #f1f8ff; color: #0366d6; padding: 2px 6px; border-radius: 3px; text-decoration: none; margin-right: 4px; margin-bottom: 4px; vertical-align: middle; font-size: 11px; } .post-tags a:hover { background: #dbedff; } .post-tags-and-actions { margin-top: 8px; display: flex; align-items: center; clear: both; } .pagination { margin-top: 30px; padding-top: 15px; border-top: 1px solid #e1e4e8; text-align: center; font-size: 13px; } .pagination a { color: #0366d6; text-decoration: none; margin: 0 8px; } .pagination a:hover { text-decoration: underline; } .pagination .current { color: #24292e; font-weight: 600; } .link-item { margin-bottom: 6px; padding: 4px 0; border-bottom: 1px solid #f0f0f0; display: flex; align-items: baseline; font-size: 13px; line-height: 1.4; } .link-item:last-child { border-bottom: none; } .link-url { flex: 0 0 auto; margin-right: 8px; } .link-url a { color: #0366d6; text-decoration: none; font-weight: 500; } .link-url a:hover { text-decoration: underline; } .link-domain { color: #24292e; font-weight: 500; } .link-path { color: #586069; font-weight: 400; } .link-backlinks { flex: 1 1 auto; font-size: 11px; color: #586069; display: flex; flex-wrap: wrap; gap: 6px; } .link-backlink { display: inline-flex; align-items: center; gap: 3px; } .link-backlink a { color: #586069; text-decoration: none; } .link-backlink a:hover { color: #0366d6; } .link-backlink-icon { color: #959da5; font-size: 10px; } .author-list { list-style: none; } .author-item { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-bottom: 1px solid #e1e4e8; transition: background 0.15s; } .author-item:hover { background: #f6f8fa; margin: 0 -8px; padding: 12px 8px; } .author-item-thumbnail { width: 40px; height: 40px; border-radius: 50%; object-fit: cover; flex-shrink: 0; border: 1px solid #e1e4e8; } .author-item-main { flex: 1; min-width: 0; } .author-item-name { font-size: 15px; font-weight: 600; margin-bottom: 2px; } .author-item-name a { color: #24292e; text-decoration: none; } .author-item-name a:hover { color: #0366d6; } .author-item-meta { display: flex; align-items: center; gap: 12px; font-size: 13px; color: #586069; flex-wrap: wrap; } .author-item-username { color: #586069; } .author-item-stat { display: inline-flex; align-items: center; gap: 4px; color: #586069; } .author-item-links { display: flex; align-items: center; gap: 6px; } .author-item-link { display: inline-flex; align-items: center; color: #586069; text-decoration: none; transition: color 0.2s; } .author-item-link:hover { color: #0366d6; } .author-item-link svg { width: 16px; height: 16px; } .author-header { background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 24px; margin-bottom: 24px; } .author-header-main { display: flex; align-items: start; gap: 20px; margin-bottom: 20px; } .author-header-thumbnail { width: 96px; height: 96px; border-radius: 50%; object-fit: cover; border: 3px solid #fff; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); flex-shrink: 0; } .author-header-info { flex: 1; } .author-header-name { font-size: 28px; font-weight: 700; color: #24292e; margin-bottom: 6px; } .author-header-username { font-size: 16px; color: #586069; margin-bottom: 12px; } .author-header-bio { font-size: 14px; color: #586069; line-height: 1.5; margin-bottom: 12px; } .author-header-links { display: flex; flex-wrap: wrap; gap: 10px; } .author-header-link { display: inline-flex; align-items: center; padding: 6px 12px; background: #fff; border: 1px solid #e1e4e8; border-radius: 4px; font-size: 13px; color: #586069; text-decoration: none; transition: all 0.2s; } .author-header-link:hover { border-color: #0366d6; color: #0366d6; } .author-header-stats { display: flex; gap: 24px; padding-top: 16px; border-top: 1px solid #e1e4e8; } .author-header-stat { display: flex; flex-direction: column; } .author-header-stat-value { font-size: 24px; font-weight: 700; color: #0366d6; } .author-header-stat-label { font-size: 12px; color: #586069; text-transform: uppercase; letter-spacing: 0.5px; } .category-list { list-style: none; } .category-list li { margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid #e1e4e8; } .category-list li:last-child { border-bottom: none; } .category-list a { color: #0366d6; text-decoration: none; font-size: 15px; } .category-list a:hover { text-decoration: underline; } .count { color: #586069; font-size: 12px; margin-left: 6px; } footer { margin-top: 40px; padding-top: 15px; border-top: 1px solid #e1e4e8; text-align: center; font-size: 11px; color: #586069; } |} let html_escape s = let buf = Buffer.create (String.length s) in String.iter (function | '<' -> Buffer.add_string buf "<" | '>' -> Buffer.add_string buf ">" | '&' -> Buffer.add_string buf "&" | '"' -> Buffer.add_string buf """ | '\'' -> Buffer.add_string buf "'" | c -> Buffer.add_char buf c ) s; Buffer.contents buf let format_date date = let open Unix in let tm = gmtime (Ptime.to_float_s date) in let months = [|"January"; "February"; "March"; "April"; "May"; "June"; "July"; "August"; "September"; "October"; "November"; "December"|] in Printf.sprintf "%s %d, %d" months.(tm.tm_mon) tm.tm_mday (1900 + tm.tm_year) let page_template ~title ~nav_current content = Printf.sprintf {| %s

River Feed

%s
|} (html_escape title) css (if nav_current = "posts" then " class=\"current\"" else "") (if nav_current = "authors" then " class=\"current\"" else "") (if nav_current = "categories" then " class=\"current\"" else "") (if nav_current = "links" then " class=\"current\"" else "") content let pagination_html ~current_page ~total_pages ~base_path = if total_pages <= 1 then "" else let prev = if current_page > 1 then let prev_page = current_page - 1 in let href = if prev_page = 1 then base_path ^ "index.html" else Printf.sprintf "%spage-%d.html" base_path prev_page in Printf.sprintf {|← Previous|} href else "" in let next = if current_page < total_pages then Printf.sprintf {|Next →|} base_path (current_page + 1) else "" in let pages = let buf = Buffer.create 256 in for i = 1 to total_pages do if i = current_page then Buffer.add_string buf (Printf.sprintf {| %d|} i) else let href = if i = 1 then base_path ^ "index.html" else Printf.sprintf "%spage-%d.html" base_path i in Buffer.add_string buf (Printf.sprintf {| %d|} href i) done; Buffer.contents buf in Printf.sprintf {||} prev pages next let full_content_from_html html_content = (* Convert HTML to markdown then to clean HTML using Cmarkit *) let markdown = Html_markdown.html_to_markdown html_content in let doc = Cmarkit.Doc.of_string markdown in Cmarkit_html.of_doc ~safe:true doc let post_excerpt_from_html html_content ~max_length = (* Convert HTML to markdown for excerpt *) let markdown = Html_markdown.html_to_markdown html_content in (* Find paragraph break after max_length *) let excerpt_md = if String.length markdown <= max_length then markdown else (* Look for double newline (paragraph break) after max_length *) let start_search = min max_length (String.length markdown - 1) in let rec find_para_break pos = if pos >= String.length markdown - 1 then String.length markdown else if pos < String.length markdown - 1 && markdown.[pos] = '\n' && markdown.[pos + 1] = '\n' then pos else find_para_break (pos + 1) in let break_pos = find_para_break start_search in let truncated = String.sub markdown 0 break_pos in if break_pos < String.length markdown then truncated ^ "..." else truncated in (* Convert markdown back to HTML using Cmarkit with custom renderer *) let doc = Cmarkit.Doc.of_string excerpt_md in (* Custom renderer that makes headings smaller and strips images *) let excerpt_customizations = let block c = function | Cmarkit.Block.Heading (h, _) -> let level = Cmarkit.Block.Heading.level h in let inline = Cmarkit.Block.Heading.inline h in (* Render heading as a strong tag with smaller font *) let style = match level with | 1 -> "font-size: 15px; font-weight: 600;" | 2 -> "font-size: 14px; font-weight: 600;" | _ -> "font-size: 14px; font-weight: 500;" in Cmarkit_renderer.Context.string c (Printf.sprintf "" style); Cmarkit_renderer.Context.inline c inline; Cmarkit_renderer.Context.string c " "; true | _ -> false in let inline _c = function | Cmarkit.Inline.Image _ -> (* Skip images in excerpts *) true | _ -> false in Cmarkit_renderer.make ~block ~inline () in let renderer = Cmarkit_renderer.compose (Cmarkit_html.renderer ~safe:true ()) excerpt_customizations in Cmarkit_renderer.doc_to_string renderer doc let render_post_html ~post ~author_username = let title = Post.title post in let author = Post.author post in let date_str = match Post.date post with | Some d -> format_date d | None -> "No date" in let link_html = match Post.link post with | Some uri -> Printf.sprintf {|%s|} (html_escape (Uri.to_string uri)) (html_escape title) | None -> html_escape title in let excerpt = post_excerpt_from_html (Post.content post) ~max_length:300 in let tags_html = match Post.tags post with | [] -> "" | tags -> let tag_links = List.map (fun tag -> Printf.sprintf {|%s|} (html_escape tag) (html_escape tag) ) tags in Printf.sprintf {|
%s
|} (String.concat "" tag_links) in Printf.sprintf {|

%s

%s
%s
|} link_html (html_escape author_username) (html_escape author) date_str excerpt tags_html let render_posts_page ~title ~posts ~current_page ~total_pages ~base_path ~nav_current = let posts_html = String.concat "\n" posts in let pagination = pagination_html ~current_page ~total_pages ~base_path in let content = posts_html ^ "\n" ^ pagination in page_template ~title ~nav_current content end