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 }}
+
+
+ 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 -%}
+
+ {%- 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 @@
{%- endfor -%}
{%- else -%}
-
+
{%- 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 @@
+