From c967aa9d7c59832805829e2ec7ced13f29a267b1 Mon Sep 17 00:00:00 2001 From: xvw Date: Sun, 11 Aug 2024 21:06:50 +0200 Subject: [PATCH 1/4] Some refactoring on test, extracting Chain --- test/gem/chain_test.ml | 55 +++++++++++++++--------------------------- test/gem/util.ml | 17 +++++++++++++ test/gem/util.mli | 11 +++++++++ 3 files changed, 48 insertions(+), 35 deletions(-) diff --git a/test/gem/chain_test.ml b/test/gem/chain_test.ml index 0da0f23..7088db9 100644 --- a/test/gem/chain_test.ml +++ b/test/gem/chain_test.ml @@ -1,40 +1,24 @@ open Gem.Model - -let make_member ident url = - let open Yocaml.Data in - ( ident, - record - [ ("id", string ident); ("main_link", record [ ("url", string url) ]) ] ) - -let m1 = make_member "m1" "https://xvw.lol" -let m2 = make_member "m2" "https://wvx.lol" -let m3 = make_member "m3" "https://xxx.lol" -let m4 = make_member "m4" "https://vvv.lol" - -let make l = - let chain, members = List.split l in - Yocaml.Data.Validation.list_of Member.validate (Yocaml.Data.list members) - |> Result.map (fun members -> (chain, members)) +open Util let from_list list = - make list - |> Result.map (fun (chain, members) -> - Chain.init ~chain ~members - |> Chain.fold - (fun acc ~pred ~curr ~succ -> - Format.asprintf "%s\n%s [< %s | %s >]" acc (Member.id curr) - (Member.id pred) (Member.id succ)) - "") + make_chain list + |> Result.map + (Chain.fold + (fun acc ~pred ~curr ~succ -> + Format.asprintf "%s\n%s [< %s | %s >]" acc (Member.id curr) + (Member.id pred) (Member.id succ)) + "") let%expect_test "Test with a regular chain" = - let ctx = from_list [ m1; m2; m3; m4 ] in + let ctx = from_list [ member_1; member_2; member_3; member_4 ] in Util.print_validated_value Format.pp_print_string ctx; [%expect {| - m1 [< m4 | m2 >] - m2 [< m1 | m3 >] - m3 [< m2 | m4 >] - m4 [< m3 | m1 >] + member-1 [< member-4 | member-2 >] + member-2 [< member-1 | member-3 >] + member-3 [< member-2 | member-4 >] + member-4 [< member-3 | member-1 >] |}] let%expect_test "Test with an empty chain" = @@ -43,14 +27,15 @@ let%expect_test "Test with an empty chain" = [%expect {| |}] let%expect_test "Test with an 1-element chain" = - let ctx = from_list [ m1 ] in + let ctx = from_list [ member_1 ] in Util.print_validated_value Format.pp_print_string ctx; - [%expect {| m1 [< m1 | m1 >] |}] + [%expect {| member-1 [< member-1 | member-1 >] |}] let%expect_test "Test with an 2-element chain" = - let ctx = from_list [ m1; m2 ] in + let ctx = from_list [ member_1; member_2 ] in Util.print_validated_value Format.pp_print_string ctx; - [%expect {| - m1 [< m2 | m2 >] - m2 [< m1 | m1 >] + [%expect + {| + member-1 [< member-2 | member-2 >] + member-2 [< member-1 | member-1 >] |}] diff --git a/test/gem/util.ml b/test/gem/util.ml index 736cb3b..ed0395e 100644 --- a/test/gem/util.ml +++ b/test/gem/util.ml @@ -29,3 +29,20 @@ let print_validated_value pp_ok x = @@ Format.asprintf "%a" (Format.pp_print_result ~ok:pp_ok ~error:pp_value_error) x + +let make_member ident url = + let open Yocaml.Data in + ( ident, + record + [ ("id", string ident); ("main_link", record [ ("url", string url) ]) ] ) + +let make_chain l = + let chain, members = List.split l in + Yocaml.Data.Validation.list_of Gem.Model.Member.validate + (Yocaml.Data.list members) + |> Result.map (fun members -> Gem.Model.Chain.init ~chain ~members) + +let member_1 = make_member "member-1" "https://xvw.lol" +let member_2 = make_member "member-2" "https://wvx.lol" +let member_3 = make_member "member-3" "https://xxx.lol" +let member_4 = make_member "member-4" "https://vvv.lol" diff --git a/test/gem/util.mli b/test/gem/util.mli index a6b2d68..3fc11ec 100644 --- a/test/gem/util.mli +++ b/test/gem/util.mli @@ -6,3 +6,14 @@ val print_validated_value : unit (** Print a validated value (for expect-test). Mostly a readable value (but not-so exhaustive) *) + +val make_member : string -> string -> string * Yocaml.Data.t + +val make_chain : + (string * Yocaml.Data.t) list -> + Gem.Model.Chain.t Yocaml.Data.Validation.validated_value + +val member_1 : string * Yocaml.Data.t +val member_2 : string * Yocaml.Data.t +val member_3 : string * Yocaml.Data.t +val member_4 : string * Yocaml.Data.t From cc92c93d7b22fbeef8d21cadca9fa9aa2fb634df Mon Sep 17 00:00:00 2001 From: xvw Date: Sun, 11 Aug 2024 21:18:31 +0200 Subject: [PATCH 2/4] Add Article Model (and Validation/Normalization) --- lib/model/article.ml | 47 ++++++++++++++++++++++++++ lib/model/article.mli | 14 ++++++++ test/gem/article_test.ml | 72 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 lib/model/article.ml create mode 100644 lib/model/article.mli create mode 100644 test/gem/article_test.ml diff --git a/lib/model/article.ml b/lib/model/article.ml new file mode 100644 index 0000000..413af8c --- /dev/null +++ b/lib/model/article.ml @@ -0,0 +1,47 @@ +type t = { + title : string; + synopsis : string option; + date : Yocaml.Archetype.Datetime.t; + tags : string list; + authors : string list; + url : Url.t; +} + +let entity_name = "Article" +let neutral = Yocaml.Metadata.required entity_name + +let validate = + let open Yocaml.Data.Validation in + record (fun fl -> + let+ title = required fl "title" string + and+ synopsis = optional fl "synopsis" string + and+ date = required fl "date" Yocaml.Archetype.Datetime.validate + and+ url = required fl "url" Url.validate + and+ tags = + optional_or fl ~default:[] "tags" @@ list_of Yocaml.Slug.validate + and+ authors = + required fl "authors" + @@ ((string $ fun x -> [ x ]) / list_of Yocaml.Slug.validate) + in + { title; synopsis; date; tags; authors; url }) + +let authors_in_chain chain { authors; _ } = + chain + |> Chain.to_list + |> List.exists (fun (x, _) -> + List.exists (String.equal @@ Member.id x) authors) + +let normalize { title; synopsis; date; tags; authors; url } = + let open Yocaml.Data in + [ + ("title", string title); + ("synopsis", option string synopsis); + ("date", Yocaml.Archetype.Datetime.normalize date); + ("tags", list_of string tags); + ("authors", list_of string authors); + ("url", Url.normalize url); + ] + +let pp ppf article = + Format.fprintf ppf "%a" Yocaml.Data.pp + (article |> normalize |> Yocaml.Data.record) diff --git a/lib/model/article.mli b/lib/model/article.mli new file mode 100644 index 0000000..d2749b0 --- /dev/null +++ b/lib/model/article.mli @@ -0,0 +1,14 @@ +(** Describes an external Article. *) + +type t +(** The type describing an article. *) + +val authors_in_chain : Chain.t -> t -> bool +(** Ensure that authors are present in chain. *) + +val pp : Format.formatter -> t -> unit + +(** {1 Dealing as metadata} *) + +include Yocaml.Required.DATA_READABLE with type t := t +include Yocaml.Required.DATA_INJECTABLE with type t := t diff --git a/test/gem/article_test.ml b/test/gem/article_test.ml new file mode 100644 index 0000000..6732872 --- /dev/null +++ b/test/gem/article_test.ml @@ -0,0 +1,72 @@ +open Yocaml +open Gem.Model +open Util + +let%expect_test "Validate a minimal article" = + let article = + let open Data in + record + [ + ("title", string "An Article"); + ("date", string "2024-08-11"); + ("authors", string "xvw"); + ("url", string "https://xvw.lol/a.html"); + ] + in + print_validated_value Article.pp (Article.validate article); + [%expect + {| + {"title": "An Article", "synopsis": null, "date": + {"year": 2024, "month": 8, "day": 11, "hour": 0, "min": 0, "sec": 0, + "has_time": false, "day_of_week": 6, "repr": + {"month": "aug", "datetime": "2024-08-11 00:00:00", "date": "2024-08-11", + "time": "00:00:00", "day_of_week": "sun"}}, + "tags": [], "authors": ["xvw"], "url": + {"url": "https://xvw.lol/a.html", "scheme": "https", "url_without_scheme": + "xvw.lol/a.html"}} + |}] + +let%expect_test "Validate a full article" = + let article = + let open Data in + record + [ + ("title", string "An Article"); + ("date", string "2024-08-11"); + ("authors", list_of string [ "xvw"; "grim" ]); + ("synopsis", string "A synopsis"); + ("url", string "https://xvw.lol/a.html"); + ("tags", list_of string [ "a"; "b"; "c-d" ]); + ] + in + print_validated_value Article.pp (Article.validate article); + [%expect + {| + {"title": "An Article", "synopsis": "A synopsis", "date": + {"year": 2024, "month": 8, "day": 11, "hour": 0, "min": 0, "sec": 0, + "has_time": false, "day_of_week": 6, "repr": + {"month": "aug", "datetime": "2024-08-11 00:00:00", "date": "2024-08-11", + "time": "00:00:00", "day_of_week": "sun"}}, + "tags": ["a", "b", "c-d"], "authors": ["xvw", "grim"], "url": + {"url": "https://xvw.lol/a.html", "scheme": "https", "url_without_scheme": + "xvw.lol/a.html"}} + |}] + +let%expect_test "Validate an invalid article" = + let article = + let open Data in + record + [ + ("title", string "An Article"); + ("authors", list_of string [ "xvw"; "grim" ]); + ("synopsis", string "A synopsis"); + ("tags", list_of string [ "a"; "b"; "c-d" ]); + ] + in + print_validated_value Article.pp (Article.validate article); + [%expect + {| + for {"title": "An Article", "authors": ["xvw", "grim"], + "synopsis": "A synopsis", "tags": ["a", "b", "c-d"]} + |}] From 955ec8d1b411ceeb5b6bb532c0f670477380a188 Mon Sep 17 00:00:00 2001 From: xvw Date: Mon, 12 Aug 2024 03:36:02 +0200 Subject: [PATCH 3/4] Add a federated blog --- data/articles/guarded-methods.yml | 13 ++++ data/articles/import-modules.yml | 10 +++ data/articles/merlin-destruct.yml | 8 +++ data/blog.md | 9 +++ lib/action/all.ml | 1 + lib/action/articles.ml | 17 +++++ lib/action/articles.mli | 3 + lib/model/article.ml | 25 ++++--- lib/model/article.mli | 1 + lib/model/articles.ml | 40 ++++++++++++ lib/model/articles.mli | 10 +++ lib/model/link.ml | 17 ++--- lib/resolver.ml | 3 + lib/sigs.mli | 3 + static/css/style.css | 105 ++++++++++++++++++++++++++++-- static/templates/blog.html | 30 +++++++++ static/templates/index.html | 2 +- static/templates/layout.html | 3 + test/gem/article_test.ml | 28 ++++---- 19 files changed, 294 insertions(+), 34 deletions(-) create mode 100644 data/articles/guarded-methods.yml create mode 100644 data/articles/import-modules.yml create mode 100644 data/articles/merlin-destruct.yml create mode 100644 data/blog.md create mode 100644 lib/action/articles.ml create mode 100644 lib/action/articles.mli create mode 100644 lib/model/articles.ml create mode 100644 lib/model/articles.mli create mode 100644 static/templates/blog.html diff --git a/data/articles/guarded-methods.yml b/data/articles/guarded-methods.yml new file mode 100644 index 0000000..6778f2f --- /dev/null +++ b/data/articles/guarded-methods.yml @@ -0,0 +1,13 @@ +title: Guarded methods using equality witnesses +synopsis: Guarded methods allow constraints to be attached to the receiver + (self) only for certain methods, so that these methods can only + be called if the receiver satisfies these constraints (these guards). + OCaml does not syntactically allow this type of method to be defined + directly. In this note, we'll look at how to encode them using a + type equality witness. +date: 2022-05-29 +authors: [xvw] +link: + url: https://xvw.lol/pages/oop-refl.html + lang: fra +tags: [ocaml, oop, refl, gadt] diff --git a/data/articles/import-modules.yml b/data/articles/import-modules.yml new file mode 100644 index 0000000..5bcbf8b --- /dev/null +++ b/data/articles/import-modules.yml @@ -0,0 +1,10 @@ +title: OCaml, modules and import schemes +synopsis: In this article, we're going to look at how generalised openings + can be used to reproduce a common practice in other languages, + which I call, somewhat pompously, import strategies, +date: 2023-10-31 +authors: [xvw] +link: + url: https://xvw.lol/pages/modules-import.html + lang: fra +tags: [ocaml, module] diff --git a/data/articles/merlin-destruct.yml b/data/articles/merlin-destruct.yml new file mode 100644 index 0000000..ac82899 --- /dev/null +++ b/data/articles/merlin-destruct.yml @@ -0,0 +1,8 @@ +title: Effective ML Through Merlin's Destruct Command +synopsis: This article presents the use of the destruct command in Merlin + and OCaml-lsp to generate missing patterns, or to specify patterns in + pattern matching. +date: 2024-05-29 +authors: [xvw] +link: https://tarides.com/blog/2024-05-29-effective-ml-through-merlin-s-destruct-command/ +tags: [ocaml, lsp, ide, emacs, vscode, vim, merlin] diff --git a/data/blog.md b/data/blog.md new file mode 100644 index 0000000..b095f79 --- /dev/null +++ b/data/blog.md @@ -0,0 +1,9 @@ +--- +page_title: Blog +description: A federated blog of Webring's member +--- + +The **Federated Blog** allows [webring members](/) to showcase federated +articles on this page from _time to time_. The list of articles is not +calculated automatically (via RSS/Atom feeds) but is the result of **a manual +addition**. diff --git a/lib/action/all.ml b/lib/action/all.ml index 0c3ddcd..193cad5 100644 --- a/lib/action/all.ml +++ b/lib/action/all.ml @@ -13,6 +13,7 @@ let run (module R : Sigs.RESOLVER) () = >>= Fonts.run (module R) >>= Css.run (module R) >>= Images.run (module R) + >>= Articles.run (module R) chain >>= Opml.run (module R) chain >>= Chain.run (module R) chain >>= Index.run (module R) chain diff --git a/lib/action/articles.ml b/lib/action/articles.ml new file mode 100644 index 0000000..7b30c6c --- /dev/null +++ b/lib/action/articles.ml @@ -0,0 +1,17 @@ +let run (module R : Sigs.RESOLVER) chain = + Yocaml.Action.write_static_file R.Target.blog + (let open Yocaml.Task in + R.track_common_dependencies + >>> Yocaml.Pipeline.track_file R.Source.articles + >>> Yocaml_yaml.Pipeline.read_file_with_metadata + (module Model.Page) + R.Source.blog + >>> first @@ Model.Articles.index chain R.Source.articles + >>> Yocaml_omd.content_to_html () + >>> Yocaml_jingoo.Pipeline.as_template + (module Model.Articles) + (R.Source.template "blog.html") + >>> Yocaml_jingoo.Pipeline.as_template + (module Model.Articles) + (R.Source.template "layout.html") + >>> drop_first ()) diff --git a/lib/action/articles.mli b/lib/action/articles.mli new file mode 100644 index 0000000..365335a --- /dev/null +++ b/lib/action/articles.mli @@ -0,0 +1,3 @@ +(** An action that builds the webring blog. *) + +val run : (module Sigs.RESOLVER) -> Model.Chain.t -> Yocaml.Action.t diff --git a/lib/model/article.ml b/lib/model/article.ml index 413af8c..e796bda 100644 --- a/lib/model/article.ml +++ b/lib/model/article.ml @@ -1,10 +1,12 @@ +open Model_util + type t = { title : string; synopsis : string option; date : Yocaml.Archetype.Datetime.t; tags : string list; authors : string list; - url : Url.t; + link : Link.t; } let entity_name = "Article" @@ -16,32 +18,37 @@ let validate = let+ title = required fl "title" string and+ synopsis = optional fl "synopsis" string and+ date = required fl "date" Yocaml.Archetype.Datetime.validate - and+ url = required fl "url" Url.validate + and+ link = required fl "link" Link.validate and+ tags = optional_or fl ~default:[] "tags" @@ list_of Yocaml.Slug.validate and+ authors = required fl "authors" @@ ((string $ fun x -> [ x ]) / list_of Yocaml.Slug.validate) in - { title; synopsis; date; tags; authors; url }) + { title; synopsis; date; tags; authors; link }) let authors_in_chain chain { authors; _ } = - chain - |> Chain.to_list - |> List.exists (fun (x, _) -> - List.exists (String.equal @@ Member.id x) authors) + let chain = Chain.to_list chain in + List.for_all + (fun author -> + chain |> List.exists (fun (x, _) -> String.equal author (Member.id x))) + authors -let normalize { title; synopsis; date; tags; authors; url } = +let normalize { title; synopsis; date; tags; authors; link } = let open Yocaml.Data in [ ("title", string title); + ("has_synopsis", has_opt synopsis); ("synopsis", option string synopsis); ("date", Yocaml.Archetype.Datetime.normalize date); ("tags", list_of string tags); + ("has_tags", has_list tags); ("authors", list_of string authors); - ("url", Url.normalize url); + ("link", Link.normalize link); ] let pp ppf article = Format.fprintf ppf "%a" Yocaml.Data.pp (article |> normalize |> Yocaml.Data.record) + +let sort a b = Yocaml.Archetype.Datetime.compare a.date b.date diff --git a/lib/model/article.mli b/lib/model/article.mli index d2749b0..2296bb5 100644 --- a/lib/model/article.mli +++ b/lib/model/article.mli @@ -7,6 +7,7 @@ val authors_in_chain : Chain.t -> t -> bool (** Ensure that authors are present in chain. *) val pp : Format.formatter -> t -> unit +val sort : t -> t -> int (** {1 Dealing as metadata} *) diff --git a/lib/model/articles.ml b/lib/model/articles.ml new file mode 100644 index 0000000..8c25e07 --- /dev/null +++ b/lib/model/articles.ml @@ -0,0 +1,40 @@ +open Model_util + +type t = { page : Page.t; articles : Article.t list } + +let from_page = Yocaml.Task.lift (fun (page, articles) -> { page; articles }) + +let fetch ?limit chain path = + Yocaml.Task.from_effect (fun () -> + let open Yocaml.Eff in + let* files = + read_directory ~on:`Source ~only:`Files + ~where:(Yocaml.Path.has_extension "yml") + path + in + + let+ articles = + List.traverse + (fun file -> + Yocaml_yaml.Eff.read_file_as_metadata + (module Article) + ~on:`Source file) + files + in + limit + |> Option.fold ~none:articles ~some:(fun limit -> + articles |> Stdlib.List.filteri (fun i _ -> i > limit)) + |> Stdlib.List.sort (fun a b -> Article.sort b a) + |> Stdlib.List.filter (Article.authors_in_chain chain)) + +let index ?limit chain path = + let open Yocaml.Task in + lift (fun x -> (x, ())) >>> second (fetch ?limit chain path) >>> from_page + +let normalize { page; articles } = + let open Yocaml.Data in + Page.normalize page + @ [ + ("articles", list_of (fun x -> record (Article.normalize x)) articles); + ("has_articles", has_list articles); + ] diff --git a/lib/model/articles.mli b/lib/model/articles.mli new file mode 100644 index 0000000..f0e5e7a --- /dev/null +++ b/lib/model/articles.mli @@ -0,0 +1,10 @@ +(** Describes a federation page of external articles published by ring members. *) + +type t +(** The type describing the federation. *) + +val index : ?limit:int -> Chain.t -> Yocaml.Path.t -> (Page.t, t) Yocaml.Task.t + +(** {1 Dealing as metadata} *) + +include Yocaml.Required.DATA_INJECTABLE with type t := t diff --git a/lib/model/link.ml b/lib/model/link.ml index db4ed1a..ce31313 100644 --- a/lib/model/link.ml +++ b/lib/model/link.ml @@ -4,14 +4,15 @@ type t = string * Lang.t * Url.t let validate = let open Yocaml.Data.Validation in - record (fun fields -> - let* url = required fields "url" Url.validate in - let+ lang = optional_or fields "lang" ~default:Lang.Eng Lang.validate - and+ title = - optional_or fields ~default:(Url.url url) "title" - (string & minimal_length 1) - in - (title, lang, url)) + (Url.validate $ fun url -> (Url.url url, Lang.Eng, url)) + / record (fun fields -> + let* url = required fields "url" Url.validate in + let+ lang = optional_or fields "lang" ~default:Lang.Eng Lang.validate + and+ title = + optional_or fields ~default:(Url.url url) "title" + (string & minimal_length 1) + in + (title, lang, url)) let normalize_underlying_link (title, lang, url) = let open Yocaml.Data in diff --git a/lib/resolver.ml b/lib/resolver.ml index ef619c2..b898602 100644 --- a/lib/resolver.ml +++ b/lib/resolver.ml @@ -12,10 +12,12 @@ module Make (R : Sigs.RESOLVABLE) = struct let templates = Path.(static / "templates") let template file = Path.(templates / file) let members = Path.(data / "members") + let articles = Path.(data / "articles") let chain = Path.(data / "chain.yml") let common_deps = [ binary; chain ] let cname = Path.(static / "CNAME") let index = Path.(data / "index.md") + let blog = Path.(data / "blog.md") let static_images = Path.(static / "images") let avatars = Path.(data / "avatars") end @@ -32,6 +34,7 @@ module Make (R : Sigs.RESOLVABLE) = struct let images = Path.(R.target / "images") let avatars = Path.(images / "avatars") let member ~id = Path.(members / id / "index.html") + let blog = Path.(R.target / "blog.html") let member_redirection ~id pred_or_succ = let target = Path.(members / id) in diff --git a/lib/sigs.mli b/lib/sigs.mli index 8440746..3b64142 100644 --- a/lib/sigs.mli +++ b/lib/sigs.mli @@ -41,8 +41,10 @@ module type RESOLVER = sig val fonts : Yocaml.Path.t val templates : Yocaml.Path.t val members : Yocaml.Path.t + val articles : Yocaml.Path.t val chain : Yocaml.Path.t val index : Yocaml.Path.t + val blog : Yocaml.Path.t val avatars : Yocaml.Path.t val template : Yocaml.Path.fragment -> Yocaml.Path.t @@ -74,5 +76,6 @@ module type RESOLVER = sig val member_redirection : id:string -> [ `Pred | `Succ ] -> Yocaml.Path.t val member : id:string -> Yocaml.Path.t val avatars : Yocaml.Path.t + val blog : Yocaml.Path.t end end diff --git a/static/css/style.css b/static/css/style.css index 510634a..0834891 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -74,6 +74,27 @@ footer p { padding: 32px 12px; } +header > .container { + display: flex; + flex-direction: row; +} + +header > .container > h1 { + flex: 1; +} + +.button, +header > .container nav > a { + font-family: "Inter"; + text-decoration: none; + background-color: var(--main-contrast-dark-color); + padding: 4px 12px; +} + +header > .container nav > a { + font-size: 80%; +} + .members { display: grid; grid-template-columns: repeat(3, 1fr); @@ -82,7 +103,7 @@ footer p { } .members > div { - border-left: 8px solid var(--main-contrast-light-color); + border-left: 6px solid var(--main-contrast-light-color); background-color: var(--main-contrast-dark-color); padding: 8px; font-family: "Inter"; @@ -122,7 +143,7 @@ footer p { font-size: 80%; } -.no-members { +.no-elts { color: var(--main-contrast-light-color); border-left: 8px solid var(--main-contrast-dark-color); padding: 32px; @@ -165,7 +186,7 @@ footer p { .member .link-list > div { background-color: var(--main-contrast-dark-color); padding: 4px 12px; - border-left: 4px solid var(--main-contrast-light-color); + font-size: 80%; } .member nav > div > a, @@ -190,13 +211,84 @@ footer p { text-align: right; } +section.articles { + margin-top: 64px; + font-size: 90%; + padding-bottom: 32px; +} + +section.articles article { + border-bottom: 4px solid var(--main-contrast-dark-color); + padding-bottom: 32px; + margin-bottom: 32px; +} + +section.articles article:last-child { + border-bottom-color: transparent; +} + +section.articles .lang > span { + margin-bottom: 12px; + background-color: var(--main-contrast-dark-color); + padding: 4px 12px; + display: inline-block; + color: var(--main-contrast-light-color); + font-family: "Inter"; + text-transform: uppercase; +} + +section.articles .date span { + color: var(--main-contrast-light-color); + font-weight: 700; + font-family: "Inter"; +} + +section.articles .date a { + margin-left: 4px; +} + +section.articles h2 { + margin-bottom: 12px; +} + +section.articles h2 > a { + text-decoration: none; + color: var(--main-light-color); + font-weight: 300; +} + +section.articles p { + padding: 12px 0; + font-weight: 200; +} + +section.articles ul.tags { + padding: 0; +} + +section.articles ul.tags > li:first-child { + padding-left: 0; +} +section.articles ul.tags > li { + list-style: none; + display: inline-block; + padding: 4px; + font-weight: 300; + font-family: "Inter"; +} + +section.articles ul.tags > li::before { + content: "#"; + color: var(--main-contrast-light-color); +} + @media all and (max-width: 1024px) { .members { grid-template-columns: repeat(2, 1fr); } } -@media all and (orientation: portrait), @media all and (max-width: 800px) { +@media all and (orientation: portrait), @media all and (max-width: 820px) { body { font-size: 1.5rem; line-height: 1.8; @@ -209,6 +301,7 @@ footer p { header h1 { line-height: 4rem; + margin-bottom: 32px; } .member { @@ -222,4 +315,8 @@ footer p { .member img { margin: 32px auto; } + + header > .container { + flex-direction: column; + } } diff --git a/static/templates/blog.html b/static/templates/blog.html new file mode 100644 index 0000000..e68a88f --- /dev/null +++ b/static/templates/blog.html @@ -0,0 +1,30 @@ +{%- autoescape false -%} {{ yocaml_body }} {%- endautoescape -%} {%- if +has_articles -%} +
+ {%- for article in articles -%} +
+
+
{{ article.link.lang }}
+

{{ article.title }}

+
+ published on {{ article.date.repr.date }} by {% for author + in article.authors -%} + {{ author }} + {%- endfor -%} +
+
+ {%- if article.has_synopsis -%} +

{{ article.synopsis }}

+ {%- endif -%} {%- if article.has_tags -%} +
    + {%- for tag in article.tags -%} +
  • {{ tag }}
  • + {%- endfor -%} +
+ {%- endif -%} +
+ {%- endfor -%} +
+{%- else -%} +
There are no federated articles
+{%- endif -%} diff --git a/static/templates/index.html b/static/templates/index.html index ab1974f..6af0d06 100644 --- a/static/templates/index.html +++ b/static/templates/index.html @@ -31,5 +31,5 @@

{{ m.display_name }}

{%- endfor -%} {%- else -%} -
The ring is empty
+
The ring is empty
{%- endif -%} diff --git a/static/templates/layout.html b/static/templates/layout.html index 8c33fd8..1f42f17 100644 --- a/static/templates/layout.html +++ b/static/templates/layout.html @@ -21,6 +21,9 @@

{{ sub_path }} {%- endif -%}

+
+ +
diff --git a/test/gem/article_test.ml b/test/gem/article_test.ml index 6732872..8c061e0 100644 --- a/test/gem/article_test.ml +++ b/test/gem/article_test.ml @@ -10,20 +10,21 @@ let%expect_test "Validate a minimal article" = ("title", string "An Article"); ("date", string "2024-08-11"); ("authors", string "xvw"); - ("url", string "https://xvw.lol/a.html"); + ("link", record [ ("url", string "https://xvw.lol/a.html") ]); ] in print_validated_value Article.pp (Article.validate article); [%expect {| - {"title": "An Article", "synopsis": null, "date": + {"title": "An Article", "has_synopsis": false, "synopsis": null, "date": {"year": 2024, "month": 8, "day": 11, "hour": 0, "min": 0, "sec": 0, "has_time": false, "day_of_week": 6, "repr": {"month": "aug", "datetime": "2024-08-11 00:00:00", "date": "2024-08-11", "time": "00:00:00", "day_of_week": "sun"}}, - "tags": [], "authors": ["xvw"], "url": - {"url": "https://xvw.lol/a.html", "scheme": "https", "url_without_scheme": - "xvw.lol/a.html"}} + "tags": [], "has_tags": false, "authors": ["xvw"], "link": + {"title": "xvw.lol/a.html", "lang": "eng", "url": + {"url": "https://xvw.lol/a.html", "scheme": "https", "url_without_scheme": + "xvw.lol/a.html"}}} |}] let%expect_test "Validate a full article" = @@ -35,21 +36,24 @@ let%expect_test "Validate a full article" = ("date", string "2024-08-11"); ("authors", list_of string [ "xvw"; "grim" ]); ("synopsis", string "A synopsis"); - ("url", string "https://xvw.lol/a.html"); + ("link", record [ ("url", string "https://xvw.lol/a.html") ]); ("tags", list_of string [ "a"; "b"; "c-d" ]); ] in print_validated_value Article.pp (Article.validate article); [%expect {| - {"title": "An Article", "synopsis": "A synopsis", "date": + {"title": "An Article", "has_synopsis": true, "synopsis": "A synopsis", + "date": {"year": 2024, "month": 8, "day": 11, "hour": 0, "min": 0, "sec": 0, "has_time": false, "day_of_week": 6, "repr": {"month": "aug", "datetime": "2024-08-11 00:00:00", "date": "2024-08-11", "time": "00:00:00", "day_of_week": "sun"}}, - "tags": ["a", "b", "c-d"], "authors": ["xvw", "grim"], "url": - {"url": "https://xvw.lol/a.html", "scheme": "https", "url_without_scheme": - "xvw.lol/a.html"}} + "tags": ["a", "b", "c-d"], "has_tags": true, "authors": ["xvw", "grim"], + "link": + {"title": "xvw.lol/a.html", "lang": "eng", "url": + {"url": "https://xvw.lol/a.html", "scheme": "https", "url_without_scheme": + "xvw.lol/a.html"}}} |}] let%expect_test "Validate an invalid article" = @@ -67,6 +71,6 @@ let%expect_test "Validate an invalid article" = [%expect {| for {"title": "An Article", "authors": ["xvw", "grim"], - "synopsis": "A synopsis", "tags": ["a", "b", "c-d"]} + missing field: link> for {"title": "An Article", "authors": ["xvw", "grim"], + "synopsis": "A synopsis", "tags": ["a", "b", "c-d"]} |}] From 579fbdd24ec87588af6913d5df833d0f908b8526 Mon Sep 17 00:00:00 2001 From: xvw Date: Mon, 12 Aug 2024 05:00:04 +0200 Subject: [PATCH 4/4] Add Atom Suppport for the federated blog --- data/blog.md | 2 +- lib/action/all.ml | 1 + lib/action/atom.ml | 6 ++++++ lib/action/atom.mli | 3 +++ lib/model/article.ml | 12 ++++++++++++ lib/model/article.mli | 1 + lib/model/articles.ml | 13 +++++++++++++ lib/model/articles.mli | 1 + lib/model/chain.ml | 13 +++++++++++++ lib/model/chain.mli | 2 ++ lib/model/member.ml | 5 +++++ lib/model/member.mli | 2 ++ lib/resolver.ml | 1 + lib/sigs.mli | 1 + static/css/style.css | 5 +++++ static/templates/layout.html | 4 ++++ 16 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 lib/action/atom.ml create mode 100644 lib/action/atom.mli diff --git a/data/blog.md b/data/blog.md index b095f79..2b1d5b5 100644 --- a/data/blog.md +++ b/data/blog.md @@ -6,4 +6,4 @@ description: A federated blog of Webring's member The **Federated Blog** allows [webring members](/) to showcase federated articles on this page from _time to time_. The list of articles is not calculated automatically (via RSS/Atom feeds) but is the result of **a manual -addition**. +addition**. You can retrieve the [ATOM feed from the federated blog](/atom.xml). diff --git a/lib/action/all.ml b/lib/action/all.ml index 193cad5..17824f9 100644 --- a/lib/action/all.ml +++ b/lib/action/all.ml @@ -14,6 +14,7 @@ let run (module R : Sigs.RESOLVER) () = >>= Css.run (module R) >>= Images.run (module R) >>= Articles.run (module R) chain + >>= Atom.run (module R) chain >>= Opml.run (module R) chain >>= Chain.run (module R) chain >>= Index.run (module R) chain diff --git a/lib/action/atom.ml b/lib/action/atom.ml new file mode 100644 index 0000000..ffbf3a2 --- /dev/null +++ b/lib/action/atom.ml @@ -0,0 +1,6 @@ +let run (module R : Sigs.RESOLVER) chain = + Yocaml.Action.write_static_file R.Target.atom + (let open Yocaml.Task in + R.track_common_dependencies + >>> Yocaml.Pipeline.track_file R.Source.articles + >>> Model.Articles.atom chain R.Source.articles) diff --git a/lib/action/atom.mli b/lib/action/atom.mli new file mode 100644 index 0000000..28b510b --- /dev/null +++ b/lib/action/atom.mli @@ -0,0 +1,3 @@ +(** An action that builds the Atom file of the federated blog. *) + +val run : (module Sigs.RESOLVER) -> Model.Chain.t -> Yocaml.Action.t diff --git a/lib/model/article.ml b/lib/model/article.ml index e796bda..68f688c 100644 --- a/lib/model/article.ml +++ b/lib/model/article.ml @@ -52,3 +52,15 @@ let pp ppf article = (article |> normalize |> Yocaml.Data.record) let sort a b = Yocaml.Archetype.Datetime.compare a.date b.date + +let to_atom chain { title; synopsis; date; tags; authors; link } = + let open Yocaml_syndication in + let title = title in + let authors = List.map (Chain.as_author chain) authors in + let updated = Datetime.make date in + let categories = List.map Category.make tags in + let url = link |> Link.url |> Url.to_string in + let summary = synopsis |> Option.map Atom.text in + let links = [ Atom.alternate url ~title ] in + Atom.entry ~authors ~updated ~categories ?summary ~links + ~title:(Atom.text title) ~id:url () diff --git a/lib/model/article.mli b/lib/model/article.mli index 2296bb5..2752718 100644 --- a/lib/model/article.mli +++ b/lib/model/article.mli @@ -8,6 +8,7 @@ val authors_in_chain : Chain.t -> t -> bool val pp : Format.formatter -> t -> unit val sort : t -> t -> int +val to_atom : Chain.t -> t -> Yocaml_syndication.Atom.entry (** {1 Dealing as metadata} *) diff --git a/lib/model/articles.ml b/lib/model/articles.ml index 8c25e07..bb2fe70 100644 --- a/lib/model/articles.ml +++ b/lib/model/articles.ml @@ -38,3 +38,16 @@ let normalize { page; articles } = ("articles", list_of (fun x -> record (Article.normalize x)) articles); ("has_articles", has_list articles); ] + +let atom chain path = + let open Yocaml_syndication in + let open Yocaml.Task in + let id = "https://ring.muhokama.fun/atom.xml" in + let title = Atom.text "ring.muhokama.fun" in + let subtitle = Atom.text "federated blog of Muhokama webring" in + let links = [ Atom.self id; Atom.link "https://ring.muhokama.fun" ] in + let updated = Atom.updated_from_entries () in + let authors = Chain.to_authors chain in + fetch chain path + >>> Atom.from ~updated ~title ~subtitle ~id ~links ~authors + (Article.to_atom chain) diff --git a/lib/model/articles.mli b/lib/model/articles.mli index f0e5e7a..e882191 100644 --- a/lib/model/articles.mli +++ b/lib/model/articles.mli @@ -4,6 +4,7 @@ type t (** The type describing the federation. *) val index : ?limit:int -> Chain.t -> Yocaml.Path.t -> (Page.t, t) Yocaml.Task.t +val atom : Chain.t -> Yocaml.Path.t -> (unit, string) Yocaml.Task.t (** {1 Dealing as metadata} *) diff --git a/lib/model/chain.ml b/lib/model/chain.ml index 9d1c12a..6300cf3 100644 --- a/lib/model/chain.ml +++ b/lib/model/chain.ml @@ -69,5 +69,18 @@ let normalize_elt { pred; curr; succ } = @ [ ("pred", string @@ Member.id pred); ("succ", string @@ Member.id succ) ] ) +let unknow_author = Yocaml_syndication.Person.make "Unknown author" let normalize = Yocaml.Data.list_of normalize_elt let is_empty = List.is_empty + +let to_authors chain = + match List.map (fun { curr; _ } -> Member.as_author curr) chain with + | [] -> (* Should never happen *) Yocaml.Nel.singleton unknow_author + | x :: xs -> Yocaml.Nel.(x :: xs) + +let as_author chain id = + chain + |> List.find_map (fun { curr; _ } -> + if String.equal (Member.id curr) id then Some curr else None) + |> Option.fold ~none:unknow_author (* Should never happen *) + ~some:Member.as_author diff --git a/lib/model/chain.mli b/lib/model/chain.mli index f42de83..72161ca 100644 --- a/lib/model/chain.mli +++ b/lib/model/chain.mli @@ -31,6 +31,8 @@ val to_list : t -> (Member.t * (Member.t * Member.t)) list val to_opml : (t, string) Yocaml.Task.t (** [to_opml] An arrow that lift a chain into an OPML file. *) +val as_author : t -> string -> Yocaml_syndication.Person.t +val to_authors : t -> Yocaml_syndication.Person.t Yocaml.Nel.t val is_empty : t -> bool val normalize : t -> Yocaml.Data.t diff --git a/lib/model/member.ml b/lib/model/member.ml index bfcc925..bba766c 100644 --- a/lib/model/member.ml +++ b/lib/model/member.ml @@ -146,3 +146,8 @@ let to_outline member = feed_to_outline title description feed) in main_feed @ additional_feeds + +let as_author m = + Yocaml_syndication.Person.make + ~uri:(m.main_link |> Link.url |> Url.url) + (display_name m) diff --git a/lib/model/member.mli b/lib/model/member.mli index 9db3d48..070a6af 100644 --- a/lib/model/member.mli +++ b/lib/model/member.mli @@ -31,6 +31,8 @@ val to_string : t -> string val equal : t -> t -> bool (** Equality between members. *) +val as_author : t -> Yocaml_syndication.Person.t + (** {1 Dealing as metadata} *) include Yocaml.Required.DATA_READABLE with type t := t diff --git a/lib/resolver.ml b/lib/resolver.ml index b898602..88c75bb 100644 --- a/lib/resolver.ml +++ b/lib/resolver.ml @@ -26,6 +26,7 @@ module Make (R : Sigs.RESOLVABLE) = struct let root = R.target let cache = Path.(R.target / "cache") let opml = Path.(R.target / "opml") + let atom = Path.(R.target / "atom.xml") let ring_opml = Path.(opml / "ring.opml") let members = Path.(R.target / "u") let css = Path.(R.target / "css") diff --git a/lib/sigs.mli b/lib/sigs.mli index 3b64142..1583ea5 100644 --- a/lib/sigs.mli +++ b/lib/sigs.mli @@ -69,6 +69,7 @@ module type RESOLVER = sig val css : Yocaml.Path.t val fonts : Yocaml.Path.t val opml : Yocaml.Path.t + val atom : Yocaml.Path.t val ring_opml : Yocaml.Path.t val index : Yocaml.Path.t val images : Yocaml.Path.t diff --git a/static/css/style.css b/static/css/style.css index 0834891..a0cd010 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -282,6 +282,11 @@ section.articles ul.tags > li::before { color: var(--main-contrast-light-color); } +footer nav { + margin-top: 32px; + font-size: 80%; +} + @media all and (max-width: 1024px) { .members { grid-template-columns: repeat(2, 1fr); diff --git a/static/templates/layout.html b/static/templates/layout.html index 1f42f17..220a60a 100644 --- a/static/templates/layout.html +++ b/static/templates/layout.html @@ -39,6 +39,10 @@

free software proudly propulsed by YOCaml

+